feat: add sorting and pinning functionality for accounts and sites
- Introduced `isPinned` and `sortOrder` fields in the `accounts` and `sites` tables to allow users to pin and sort their accounts and sites. - Updated API routes to handle pinning and sorting operations for accounts and sites. - Enhanced the frontend to support sorting by balance and custom order, as well as toggling the pin status of accounts and sites. - Added tests for sorting and reordering logic to ensure correct functionality. - Updated documentation to reflect changes in token management and new features.
This commit is contained in:
@@ -210,7 +210,9 @@ services:
|
||||
EOF
|
||||
|
||||
# 设置令牌并启动
|
||||
# AUTH_TOKEN = 管理后台初始管理员令牌(登录后台时输入这个值)
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
# PROXY_TOKEN = 下游客户端调用 /v1/* 使用的令牌
|
||||
export PROXY_TOKEN=your-proxy-sk-token
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -236,6 +238,11 @@ docker run -d --name metapi \
|
||||
> [!IMPORTANT]
|
||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录中,升级不影响已有数据。
|
||||
|
||||
> [!TIP]
|
||||
> 初始管理员令牌就是启动时配置的 `AUTH_TOKEN`。
|
||||
> 如果是非 Compose 场景且你没有显式设置 `AUTH_TOKEN`,默认值为 `change-me-admin-token`(仅用于本地调试)。
|
||||
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用修改后的新令牌。
|
||||
|
||||
### 升级
|
||||
|
||||
```bash
|
||||
@@ -266,8 +273,8 @@ docker compose pull && docker compose up -d && docker image prune -f
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
| ----------------------------- | ------------------------------------------- | ----------------------- |
|
||||
| `AUTH_TOKEN` | 管理后台登录令牌(**必须修改**) | `change-me` |
|
||||
| `PROXY_TOKEN` | 代理 API Bearer Token(**必须修改**) | `change-me-proxy` |
|
||||
| `AUTH_TOKEN` | 管理后台登录令牌(**必须修改**) | `change-me-admin-token` |
|
||||
| `PROXY_TOKEN` | 代理 API Bearer Token(**必须修改**) | `change-me-proxy-sk-token` |
|
||||
| `PORT` | 服务监听端口 | `4000` |
|
||||
| `DATA_DIR` | 数据目录(SQLite 数据库) | `./data` |
|
||||
| `TZ` | 时区 | `Asia/Shanghai` |
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `AUTH_TOKEN` | 管理后台登录令牌 | `change-me` |
|
||||
| `PROXY_TOKEN` | 代理接口 Bearer Token(下游客户端使用此值作为 API Key) | `change-me-proxy` |
|
||||
| `AUTH_TOKEN` | 管理后台登录令牌 | `change-me-admin-token` |
|
||||
| `PROXY_TOKEN` | 代理接口 Bearer Token(下游客户端使用此值作为 API Key) | `change-me-proxy-sk-token` |
|
||||
|
||||
## 基础配置
|
||||
|
||||
|
||||
+5
-4
@@ -17,11 +17,12 @@
|
||||
|
||||
### Q: 登录失败,提示令牌无效
|
||||
|
||||
**A:** 检查 `AUTH_TOKEN` 是否与部署环境一致。注意:
|
||||
**A:** 先确认你输入的是“管理员令牌”而不是代理令牌。登录后台使用的是 `AUTH_TOKEN`,注意:
|
||||
|
||||
- 使用 `.env` 文件时,确认文件路径正确
|
||||
- 不要混用 `.env.example` 的默认值
|
||||
- 环境变量中的值不需要加引号
|
||||
- 初始管理员令牌 = 启动时设置的 `AUTH_TOKEN`
|
||||
- 如果你在非 Compose 场景未显式设置 `AUTH_TOKEN`,默认值是 `change-me-admin-token`(仅建议本地调试)
|
||||
- 若复用旧 `data/` 目录,系统会优先读取数据库中的 `auth_token` 设置(可能覆盖当前环境变量)
|
||||
- 使用 `.env` 文件时,确认文件路径正确,且值不需要加引号
|
||||
|
||||
### Q: Docker Compose 启动报错 `AUTH_TOKEN is required`
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ services:
|
||||
### 3. 设置令牌并启动
|
||||
|
||||
```bash
|
||||
# AUTH_TOKEN = 管理后台初始管理员令牌(登录后台时输入这个值)
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
# PROXY_TOKEN = 下游客户端调用 /v1/* 使用的令牌
|
||||
export PROXY_TOKEN=your-proxy-sk-token
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -52,6 +54,11 @@ docker compose up -d
|
||||
|
||||
打开 `http://localhost:4000`,使用 `AUTH_TOKEN` 的值登录。
|
||||
|
||||
> [!TIP]
|
||||
> 初始管理员令牌就是启动时配置的 `AUTH_TOKEN`。
|
||||
> 如果未显式设置(非 Compose 场景),默认值为 `change-me-admin-token`(仅建议本地调试)。
|
||||
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用新令牌。
|
||||
|
||||
## 方式二:本地开发启动
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE `sites` ADD COLUMN `is_pinned` integer DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `sites` ADD COLUMN `sort_order` integer DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD COLUMN `is_pinned` integer DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD COLUMN `sort_order` integer DEFAULT 0;
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1772079351462,
|
||||
"tag": "0003_great_blizzard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1772320800000,
|
||||
"tag": "0004_sorting_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,9 +22,9 @@ function parseCsvList(value: string | undefined): string[] {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
authToken: process.env.AUTH_TOKEN || 'change-me',
|
||||
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy',
|
||||
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me',
|
||||
authToken: process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: process.env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
webhookUrl: process.env.WEBHOOK_URL || '',
|
||||
|
||||
@@ -7,6 +7,8 @@ export const sites = sqliteTable('sites', {
|
||||
url: text('url').notNull(),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api'
|
||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
apiKey: text('api_key'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
@@ -26,6 +28,8 @@ export const accounts = sqliteTable('accounts', {
|
||||
unitCost: real('unit_cost'),
|
||||
valueScore: real('value_score').default(0),
|
||||
status: text('status').default('active'), // 'active' | 'disabled' | 'expired'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
|
||||
lastCheckinAt: text('last_checkin_at'),
|
||||
lastBalanceRefresh: text('last_balance_refresh'),
|
||||
|
||||
@@ -31,6 +31,31 @@ type AccountHealthRefreshResult = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
function normalizePinnedFlag(input: unknown): boolean | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
if (typeof input === 'boolean') return input;
|
||||
if (typeof input === 'number') return input !== 0;
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(input: unknown): number | null {
|
||||
if (input === undefined || input === null || input === '') return null;
|
||||
const parsed = Number.parseInt(String(input), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function getNextAccountSortOrder(): number {
|
||||
const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
|
||||
return {
|
||||
total: results.length,
|
||||
@@ -246,6 +271,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
apiToken: preferredApiToken || undefined,
|
||||
checkinEnabled: true,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
accountId = created.id;
|
||||
}
|
||||
@@ -435,6 +462,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
apiToken: apiToken || undefined,
|
||||
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
|
||||
if (apiToken) {
|
||||
@@ -469,13 +498,30 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update an account
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request) => {
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const updates: any = {};
|
||||
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
|
||||
if (body[key] !== undefined) updates[key] = body[key];
|
||||
}
|
||||
|
||||
if (body.isPinned !== undefined) {
|
||||
const normalizedPinned = normalizePinnedFlag(body.isPinned);
|
||||
if (normalizedPinned === null) {
|
||||
return reply.code(400).send({ message: 'Invalid isPinned value. Expected boolean.' });
|
||||
}
|
||||
updates.isPinned = normalizedPinned;
|
||||
}
|
||||
|
||||
if (body.sortOrder !== undefined) {
|
||||
const normalizedSortOrder = normalizeSortOrder(body.sortOrder);
|
||||
if (normalizedSortOrder === null) {
|
||||
return reply.code(400).send({ message: 'Invalid sortOrder value. Expected non-negative integer.' });
|
||||
}
|
||||
updates.sortOrder = normalizedSortOrder;
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
|
||||
|
||||
@@ -11,19 +11,60 @@ function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePinnedFlag(input: unknown): boolean | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
if (typeof input === 'boolean') return input;
|
||||
if (typeof input === 'number') return input !== 0;
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(input: unknown): number | null {
|
||||
if (input === undefined || input === null || input === '') return null;
|
||||
const parsed = Number.parseInt(String(input), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
export async function sitesRoutes(app: FastifyInstance) {
|
||||
// List all sites
|
||||
app.get('/api/sites', async () => {
|
||||
return db.select().from(schema.sites).all();
|
||||
const siteRows = db.select().from(schema.sites).all();
|
||||
const accountRows = db.select().from(schema.accounts).all();
|
||||
|
||||
const totalBalanceBySiteId: Record<number, number> = {};
|
||||
for (const account of accountRows) {
|
||||
totalBalanceBySiteId[account.siteId] = (totalBalanceBySiteId[account.siteId] || 0) + (account.balance || 0);
|
||||
}
|
||||
|
||||
return siteRows.map((site) => ({
|
||||
...site,
|
||||
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
||||
}));
|
||||
});
|
||||
|
||||
// Add a site
|
||||
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, apiKey, status } = request.body;
|
||||
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, apiKey, status, isPinned, sortOrder } = request.body;
|
||||
const normalizedStatus = normalizeSiteStatus(status);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
}
|
||||
const normalizedPinned = normalizePinnedFlag(isPinned);
|
||||
if (isPinned !== undefined && normalizedPinned === null) {
|
||||
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
||||
}
|
||||
const normalizedSortOrder = normalizeSortOrder(sortOrder);
|
||||
if (sortOrder !== undefined && normalizedSortOrder === null) {
|
||||
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
|
||||
}
|
||||
|
||||
const existingSites = db.select().from(schema.sites).all();
|
||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||
|
||||
let detectedPlatform = platform;
|
||||
if (!detectedPlatform) {
|
||||
@@ -39,12 +80,14 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
platform: detectedPlatform,
|
||||
apiKey,
|
||||
status: normalizedStatus ?? 'active',
|
||||
isPinned: normalizedPinned ?? false,
|
||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||
}).returning().get();
|
||||
return result;
|
||||
});
|
||||
|
||||
// Update a site
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
@@ -61,12 +104,22 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
}
|
||||
const normalizedPinned = normalizePinnedFlag(body.isPinned);
|
||||
if (body.isPinned !== undefined && normalizedPinned === null) {
|
||||
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
||||
}
|
||||
const normalizedSortOrder = normalizeSortOrder(body.sortOrder);
|
||||
if (body.sortOrder !== undefined && normalizedSortOrder === null) {
|
||||
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
|
||||
}
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
|
||||
if (body.status !== undefined) updates.status = normalizedStatus;
|
||||
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const VERSION = 'v1';
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function buildKey(): Buffer {
|
||||
const secret = (config.accountCredentialSecret || '').trim() || config.authToken || 'change-me';
|
||||
const secret = (config.accountCredentialSecret || '').trim() || config.authToken || 'change-me-admin-token';
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,8 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
url: siteUrl,
|
||||
platform,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: sites.length,
|
||||
apiKey: null,
|
||||
createdAt: toIsoString(item.created_at),
|
||||
updatedAt: toIsoString(item.updated_at),
|
||||
@@ -213,6 +215,8 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: asBoolean(item.disabled, false) ? 'disabled' : 'active',
|
||||
isPinned: false,
|
||||
sortOrder: accounts.length,
|
||||
checkinEnabled: asBoolean(checkin.autoCheckInEnabled, true),
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
|
||||
@@ -1196,6 +1196,45 @@ body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accounts-actions-col {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.accounts-actions-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.accounts-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.sites-actions-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.sites-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sites-url-cell {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.sites-url-link {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ===== Status Badge ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -1454,6 +1493,16 @@ textarea {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accounts-page-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sites-page-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ===== Chart Placeholder ===== */
|
||||
.chart-container {
|
||||
background: var(--color-bg-card);
|
||||
@@ -2681,8 +2730,103 @@ textarea {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.accounts-page-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.accounts-page-actions .btn-soft-primary,
|
||||
.accounts-page-actions .btn-primary {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accounts-actions-col {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.accounts-table th,
|
||||
.accounts-table td {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.accounts-row-actions .btn-link {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 165px;
|
||||
}
|
||||
|
||||
.sites-table th,
|
||||
.sites-table td {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.sites-row-actions .btn-link {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Responsive Adjustments ===== */
|
||||
@media (max-width: 900px) {
|
||||
.accounts-page-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.accounts-page-actions .accounts-sort-select {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.accounts-page-actions .btn-soft-primary,
|
||||
.accounts-page-actions .btn-primary {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.accounts-actions-col {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.accounts-table th:nth-child(5),
|
||||
.accounts-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sites-page-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.sites-page-actions .sites-sort-select {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.sites-page-actions .btn-primary {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sites-actions-col {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.sites-table th:nth-child(2),
|
||||
.sites-table td:nth-child(2),
|
||||
.sites-table th:nth-child(6),
|
||||
.sites-table td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
|
||||
export default function Accounts() {
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [sites, setSites] = useState<any[]>([]);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
||||
@@ -24,6 +26,11 @@ export default function Accounts() {
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const sortedAccounts = useMemo(
|
||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||
[accounts, sortMode],
|
||||
);
|
||||
|
||||
const handleLoginAdd = async () => {
|
||||
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
|
||||
setSaving(true);
|
||||
@@ -171,11 +178,55 @@ export default function Accounts() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async (account: any) => {
|
||||
const key = `pin-toggle-${account.id}`;
|
||||
const nextPinned = !account.isPinned;
|
||||
setActionLoading((s) => ({ ...s, [key]: true }));
|
||||
try {
|
||||
await api.updateAccount(account.id, { isPinned: nextPinned });
|
||||
toast.success(nextPinned ? '账号已置顶' : '账号已取消置顶');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '切换账号置顶失败');
|
||||
} finally {
|
||||
setActionLoading((s) => ({ ...s, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCustomOrder = async (account: any, direction: 'up' | 'down') => {
|
||||
const key = `reorder-${account.id}`;
|
||||
const updates = buildCustomReorderUpdates(accounts, account.id, direction);
|
||||
if (updates.length === 0) return;
|
||||
|
||||
setActionLoading((s) => ({ ...s, [key]: true }));
|
||||
try {
|
||||
await Promise.all(updates.map((update) => api.updateAccount(update.id, { sortOrder: update.sortOrder })));
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新账号排序失败');
|
||||
} finally {
|
||||
setActionLoading((s) => ({ ...s, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('账号管理')}</h2>
|
||||
<div className="page-actions">
|
||||
<div className="page-actions accounts-page-actions">
|
||||
<div className="accounts-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')} disabled={actionLoading['checkin-all']}
|
||||
className="btn btn-soft-primary">
|
||||
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
|
||||
@@ -342,7 +393,7 @@ export default function Accounts() {
|
||||
{/* Accounts Table */}
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{accounts.length > 0 ? (
|
||||
<table className="data-table">
|
||||
<table className="data-table accounts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
@@ -351,11 +402,11 @@ export default function Accounts() {
|
||||
<th>余额</th>
|
||||
<th>已用</th>
|
||||
<th>签到</th>
|
||||
<th style={{ textAlign: 'right' }}>操作</th>
|
||||
<th className="accounts-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.map((a: any, i: number) => (
|
||||
{sortedAccounts.map((a: any, i: number) => (
|
||||
<tr key={a.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
|
||||
<td>
|
||||
@@ -427,8 +478,33 @@ export default function Accounts() {
|
||||
: (a.checkinEnabled ? '开启' : '关闭')}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="accounts-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'up')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'down')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
|
||||
className="btn btn-link btn-link-primary">
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
|
||||
+97
-10
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteForm,
|
||||
@@ -18,6 +20,9 @@ type SiteRow = {
|
||||
platform?: string;
|
||||
status?: string;
|
||||
apiKey?: string;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
totalBalance?: number;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
@@ -32,12 +37,15 @@ const platformColors: Record<string, string> = {
|
||||
|
||||
export default function Sites() {
|
||||
const [sites, setSites] = useState<SiteRow[]>([]);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [editor, setEditor] = useState<SiteEditorState | null>(null);
|
||||
const [form, setForm] = useState<SiteForm>(emptySiteForm());
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const isEditing = editor?.mode === 'edit';
|
||||
@@ -56,6 +64,11 @@ export default function Sites() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const sortedSites = useMemo(
|
||||
() => sortItemsForDisplay(sites, sortMode, (site) => site.totalBalance || 0),
|
||||
[sites, sortMode],
|
||||
);
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditor(null);
|
||||
setForm(emptySiteForm());
|
||||
@@ -155,13 +168,57 @@ export default function Sites() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async (site: SiteRow) => {
|
||||
const nextPinned = !site.isPinned;
|
||||
setPinningSiteId(site.id);
|
||||
try {
|
||||
await api.updateSite(site.id, { isPinned: nextPinned });
|
||||
toast.success(nextPinned ? `站点 "${site.name}" 已置顶` : `站点 "${site.name}" 已取消置顶`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '切换置顶失败');
|
||||
} finally {
|
||||
setPinningSiteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCustomOrder = async (site: SiteRow, direction: 'up' | 'down') => {
|
||||
const updates = buildCustomReorderUpdates(sites, site.id, direction);
|
||||
if (updates.length === 0) return;
|
||||
|
||||
setOrderingSiteId(site.id);
|
||||
try {
|
||||
await Promise.all(updates.map((update) => api.updateSite(update.id, { sortOrder: update.sortOrder })));
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新排序失败');
|
||||
} finally {
|
||||
setOrderingSiteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('站点管理')}</h2>
|
||||
<button onClick={openAdd} className="btn btn-primary">
|
||||
{isAdding ? '取消' : '+ 添加站点'}
|
||||
</button>
|
||||
<div className="page-actions sites-page-actions">
|
||||
<div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={sortMode}
|
||||
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
|
||||
options={[
|
||||
{ value: 'custom', label: '自定义排序' },
|
||||
{ value: 'balance-desc', label: '余额高到低' },
|
||||
{ value: 'balance-asc', label: '余额低到高' },
|
||||
]}
|
||||
placeholder="自定义排序"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={openAdd} className="btn btn-primary">
|
||||
{isAdding ? '取消' : '+ 添加站点'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editor && (
|
||||
@@ -265,19 +322,20 @@ export default function Sites() {
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{sites.length > 0 ? (
|
||||
<table className="data-table">
|
||||
<table className="data-table sites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
<th style={{ textAlign: 'right' }}>操作</th>
|
||||
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sites.map((site, i) => (
|
||||
{sortedSites.map((site, i) => (
|
||||
<tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<a
|
||||
@@ -292,11 +350,12 @@ export default function Sites() {
|
||||
{site.name}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ maxWidth: 300 }}>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
@@ -308,6 +367,9 @@ export default function Sites() {
|
||||
{site.url}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
${(site.totalBalance || 0).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
@@ -335,8 +397,33 @@ export default function Sites() {
|
||||
{formatDateTimeLocal(site.createdAt)}
|
||||
</a>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<td className="sites-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="sites-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(site)}
|
||||
disabled={pinningSiteId === site.id}
|
||||
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'up')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(site, 'down')}
|
||||
disabled={orderingSiteId === site.id}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEdit(site)}
|
||||
className="btn btn-link btn-link-primary"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './listSorting.js';
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
isPinned?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
balance?: number | null;
|
||||
};
|
||||
|
||||
function ids(items: Item[]): number[] {
|
||||
return items.map((item) => item.id);
|
||||
}
|
||||
|
||||
describe('sortItemsForDisplay', () => {
|
||||
const base: Item[] = [
|
||||
{ id: 1, isPinned: false, sortOrder: 2, balance: 5 },
|
||||
{ id: 2, isPinned: true, sortOrder: 1, balance: 1 },
|
||||
{ id: 3, isPinned: false, sortOrder: 0, balance: 20 },
|
||||
{ id: 4, isPinned: true, sortOrder: 0, balance: 10 },
|
||||
];
|
||||
|
||||
it('keeps pinned items first in custom mode', () => {
|
||||
const mode: SortMode = 'custom';
|
||||
const sorted = sortItemsForDisplay(base, mode, (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
|
||||
});
|
||||
|
||||
it('sorts by balance desc while keeping pinned items first', () => {
|
||||
const sorted = sortItemsForDisplay(base, 'balance-desc', (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
|
||||
});
|
||||
|
||||
it('sorts by balance asc while keeping pinned items first', () => {
|
||||
const sorted = sortItemsForDisplay(base, 'balance-asc', (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([2, 4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCustomReorderUpdates', () => {
|
||||
const list: Item[] = [
|
||||
{ id: 10, isPinned: true, sortOrder: 0 },
|
||||
{ id: 11, isPinned: true, sortOrder: 1 },
|
||||
{ id: 20, isPinned: false, sortOrder: 0 },
|
||||
{ id: 21, isPinned: false, sortOrder: 1 },
|
||||
];
|
||||
|
||||
it('reorders only inside the same pinned group', () => {
|
||||
const updates = buildCustomReorderUpdates(list, 20, 'up');
|
||||
// First unpinned item cannot move above pinned group.
|
||||
expect(updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns normalized sortOrder updates after moving down', () => {
|
||||
const updates = buildCustomReorderUpdates(list, 20, 'down');
|
||||
expect(updates).toEqual([
|
||||
{ id: 21, sortOrder: 0 },
|
||||
{ id: 20, sortOrder: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export type SortMode = 'custom' | 'balance-desc' | 'balance-asc';
|
||||
|
||||
type SortableBase = {
|
||||
id: number;
|
||||
isPinned?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export function sortItemsForDisplay<T extends SortableBase>(
|
||||
items: T[],
|
||||
mode: SortMode,
|
||||
getBalance: (item: T) => number,
|
||||
): T[] {
|
||||
const list = [...items];
|
||||
const customComparator = (a: T, b: T) => {
|
||||
const aPinned = a.isPinned ? 1 : 0;
|
||||
const bPinned = b.isPinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
|
||||
const aOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.id - b.id;
|
||||
};
|
||||
|
||||
if (mode === 'custom') {
|
||||
return list.sort(customComparator);
|
||||
}
|
||||
|
||||
return list.sort((a, b) => {
|
||||
const aPinned = a.isPinned ? 1 : 0;
|
||||
const bPinned = b.isPinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
|
||||
const aBalance = Number.isFinite(getBalance(a)) ? getBalance(a) : 0;
|
||||
const bBalance = Number.isFinite(getBalance(b)) ? getBalance(b) : 0;
|
||||
if (aBalance !== bBalance) {
|
||||
return mode === 'balance-desc' ? bBalance - aBalance : aBalance - bBalance;
|
||||
}
|
||||
|
||||
return customComparator(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCustomReorderUpdates<T extends SortableBase>(
|
||||
items: T[],
|
||||
targetId: number,
|
||||
direction: 'up' | 'down',
|
||||
): Array<{ id: number; sortOrder: number }> {
|
||||
const sorted = sortItemsForDisplay(items, 'custom', () => 0);
|
||||
const target = sorted.find((item) => item.id === targetId);
|
||||
if (!target) return [];
|
||||
|
||||
const targetPinned = !!target.isPinned;
|
||||
const group = sorted.filter((item) => !!item.isPinned === targetPinned);
|
||||
const index = group.findIndex((item) => item.id === targetId);
|
||||
if (index < 0) return [];
|
||||
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= group.length) return [];
|
||||
|
||||
const next = [...group];
|
||||
const temp = next[index];
|
||||
next[index] = next[swapIndex];
|
||||
next[swapIndex] = temp;
|
||||
|
||||
const updates: Array<{ id: number; sortOrder: number }> = [];
|
||||
next.forEach((item, idx) => {
|
||||
const prev = Number.isFinite(item.sortOrder as number) ? Number(item.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
if (prev !== idx) {
|
||||
updates.push({ id: item.id, sortOrder: idx });
|
||||
}
|
||||
});
|
||||
|
||||
return updates;
|
||||
}
|
||||
Reference in New Issue
Block a user