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:
cita
2026-02-28 23:45:30 +08:00
parent 08eaf11ea2
commit 44af9a91cf
17 changed files with 647 additions and 67 deletions
+9 -2
View File
@@ -210,7 +210,9 @@ services:
EOF EOF
# 设置令牌并启动 # 设置令牌并启动
# AUTH_TOKEN = 管理后台初始管理员令牌(登录后台时输入这个值)
export AUTH_TOKEN=your-admin-token export AUTH_TOKEN=your-admin-token
# PROXY_TOKEN = 下游客户端调用 /v1/* 使用的令牌
export PROXY_TOKEN=your-proxy-sk-token export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d docker compose up -d
``` ```
@@ -236,6 +238,11 @@ docker run -d --name metapi \
> [!IMPORTANT] > [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录中,升级不影响已有数据。 > 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录中,升级不影响已有数据。
> [!TIP]
> 初始管理员令牌就是启动时配置的 `AUTH_TOKEN`。
> 如果是非 Compose 场景且你没有显式设置 `AUTH_TOKEN`,默认值为 `change-me-admin-token`(仅用于本地调试)。
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用修改后的新令牌。
### 升级 ### 升级
```bash ```bash
@@ -266,8 +273,8 @@ docker compose pull && docker compose up -d && docker image prune -f
| 变量名 | 说明 | 默认值 | | 变量名 | 说明 | 默认值 |
| ----------------------------- | ------------------------------------------- | ----------------------- | | ----------------------------- | ------------------------------------------- | ----------------------- |
| `AUTH_TOKEN` | 管理后台登录令牌(**必须修改**) | `change-me` | | `AUTH_TOKEN` | 管理后台登录令牌(**必须修改**) | `change-me-admin-token` |
| `PROXY_TOKEN` | 代理 API Bearer Token(**必须修改**) | `change-me-proxy` | | `PROXY_TOKEN` | 代理 API Bearer Token(**必须修改**) | `change-me-proxy-sk-token` |
| `PORT` | 服务监听端口 | `4000` | | `PORT` | 服务监听端口 | `4000` |
| `DATA_DIR` | 数据目录(SQLite 数据库) | `./data` | | `DATA_DIR` | 数据目录(SQLite 数据库) | `./data` |
| `TZ` | 时区 | `Asia/Shanghai` | | `TZ` | 时区 | `Asia/Shanghai` |
+2 -2
View File
@@ -12,8 +12,8 @@
| 变量名 | 说明 | 默认值 | | 变量名 | 说明 | 默认值 |
|--------|------|--------| |--------|------|--------|
| `AUTH_TOKEN` | 管理后台登录令牌 | `change-me` | | `AUTH_TOKEN` | 管理后台登录令牌 | `change-me-admin-token` |
| `PROXY_TOKEN` | 代理接口 Bearer Token(下游客户端使用此值作为 API Key) | `change-me-proxy` | | `PROXY_TOKEN` | 代理接口 Bearer Token(下游客户端使用此值作为 API Key) | `change-me-proxy-sk-token` |
## 基础配置 ## 基础配置
+5 -4
View File
@@ -17,11 +17,12 @@
### Q: 登录失败,提示令牌无效 ### Q: 登录失败,提示令牌无效
**A:** 检查 `AUTH_TOKEN` 是否与部署环境一致。注意: **A:** 先确认你输入的是“管理员令牌”而不是代理令牌。登录后台使用的是 `AUTH_TOKEN`,注意:
- 使用 `.env` 文件时,确认文件路径正确 - 初始管理员令牌 = 启动时设置的 `AUTH_TOKEN`
- 不要混用 `.env.example` 的默认值 - 如果你在非 Compose 场景未显式设置 `AUTH_TOKEN`,默认值是 `change-me-admin-token`(仅建议本地调试)
- 环境变量中的值不需要加引号 - 若复用旧 `data/` 目录,系统会优先读取数据库中的 `auth_token` 设置(可能覆盖当前环境变量)
- 使用 `.env` 文件时,确认文件路径正确,且值不需要加引号
### Q: Docker Compose 启动报错 `AUTH_TOKEN is required` ### Q: Docker Compose 启动报错 `AUTH_TOKEN is required`
+7
View File
@@ -43,7 +43,9 @@ services:
### 3. 设置令牌并启动 ### 3. 设置令牌并启动
```bash ```bash
# AUTH_TOKEN = 管理后台初始管理员令牌(登录后台时输入这个值)
export AUTH_TOKEN=your-admin-token export AUTH_TOKEN=your-admin-token
# PROXY_TOKEN = 下游客户端调用 /v1/* 使用的令牌
export PROXY_TOKEN=your-proxy-sk-token export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d docker compose up -d
``` ```
@@ -52,6 +54,11 @@ docker compose up -d
打开 `http://localhost:4000`,使用 `AUTH_TOKEN` 的值登录。 打开 `http://localhost:4000`,使用 `AUTH_TOKEN` 的值登录。
> [!TIP]
> 初始管理员令牌就是启动时配置的 `AUTH_TOKEN`。
> 如果未显式设置(非 Compose 场景),默认值为 `change-me-admin-token`(仅建议本地调试)。
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用新令牌。
## 方式二:本地开发启动 ## 方式二:本地开发启动
```bash ```bash
+7
View File
@@ -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;
+8 -1
View File
@@ -29,6 +29,13 @@
"when": 1772079351462, "when": 1772079351462,
"tag": "0003_great_blizzard", "tag": "0003_great_blizzard",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1772320800000,
"tag": "0004_sorting_preferences",
"breakpoints": true
} }
] ]
} }
+3 -3
View File
@@ -22,9 +22,9 @@ function parseCsvList(value: string | undefined): string[] {
} }
export const config = { export const config = {
authToken: process.env.AUTH_TOKEN || 'change-me', authToken: process.env.AUTH_TOKEN || 'change-me-admin-token',
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy', proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy-sk-token',
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me', accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: process.env.CHECKIN_CRON || '0 8 * * *', checkinCron: process.env.CHECKIN_CRON || '0 8 * * *',
balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *', balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *',
webhookUrl: process.env.WEBHOOK_URL || '', webhookUrl: process.env.WEBHOOK_URL || '',
+4
View File
@@ -7,6 +7,8 @@ export const sites = sqliteTable('sites', {
url: text('url').notNull(), url: text('url').notNull(),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api'
status: text('status').notNull().default('active'), // 'active' | 'disabled' 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'), apiKey: text('api_key'),
createdAt: text('created_at').default(sql`(datetime('now'))`), createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_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'), unitCost: real('unit_cost'),
valueScore: real('value_score').default(0), valueScore: real('value_score').default(0),
status: text('status').default('active'), // 'active' | 'disabled' | 'expired' 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), checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
lastCheckinAt: text('last_checkin_at'), lastCheckinAt: text('last_checkin_at'),
lastBalanceRefresh: text('last_balance_refresh'), lastBalanceRefresh: text('last_balance_refresh'),
+79 -33
View File
@@ -22,14 +22,39 @@ type AccountWithSiteRow = {
sites: typeof schema.sites.$inferSelect; sites: typeof schema.sites.$inferSelect;
}; };
type AccountHealthRefreshResult = { type AccountHealthRefreshResult = {
accountId: number; accountId: number;
username: string | null; username: string | null;
siteName: string; siteName: string;
status: 'success' | 'failed' | 'skipped'; status: 'success' | 'failed' | 'skipped';
state: RuntimeHealthState; state: RuntimeHealthState;
message: string; 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[]) { function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
return { return {
@@ -239,16 +264,18 @@ export async function accountsRoutes(app: FastifyInstance) {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run(); }).where(eq(schema.accounts.id, existing.id)).run();
} else { } else {
const created = db.insert(schema.accounts).values({ const created = db.insert(schema.accounts).values({
siteId, siteId,
username, username,
accessToken: loginResult.accessToken, accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined, apiToken: preferredApiToken || undefined,
checkinEnabled: true, checkinEnabled: true,
extraConfig, extraConfig,
}).returning().get(); isPinned: false,
accountId = created.id; sortOrder: getNextAccountSortOrder(),
} }).returning().get();
accountId = created.id;
}
const result = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get(); const result = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
if (!result) { if (!result) {
@@ -428,14 +455,16 @@ export async function accountsRoutes(app: FastifyInstance) {
extraConfig = mergeAccountExtraConfig(undefined, { platformUserId: resolvedPlatformUserId }); extraConfig = mergeAccountExtraConfig(undefined, { platformUserId: resolvedPlatformUserId });
} }
const result = db.insert(schema.accounts).values({ const result = db.insert(schema.accounts).values({
siteId: body.siteId, siteId: body.siteId,
username: username || undefined, username: username || undefined,
accessToken, accessToken,
apiToken: apiToken || undefined, apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false, checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig, extraConfig,
}).returning().get(); isPinned: false,
sortOrder: getNextAccountSortOrder(),
}).returning().get();
if (apiToken) { if (apiToken) {
try { try {
@@ -469,15 +498,32 @@ export async function accountsRoutes(app: FastifyInstance) {
}); });
// Update an account // 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 id = parseInt(request.params.id);
const body = request.body as Record<string, unknown>; const body = request.body as Record<string, unknown>;
const updates: any = {}; const updates: any = {};
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) { for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
if (body[key] !== undefined) updates[key] = body[key]; if (body[key] !== undefined) updates[key] = body[key];
} }
updates.updatedAt = new Date().toISOString();
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run(); 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();
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) { if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try { try {
+57 -4
View File
@@ -11,19 +11,60 @@ function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
return 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) { export async function sitesRoutes(app: FastifyInstance) {
// List all sites // List all sites
app.get('/api/sites', async () => { 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 // Add a site
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites', async (request, reply) => { 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 } = request.body; const { name, url, platform, apiKey, status, isPinned, sortOrder } = request.body;
const normalizedStatus = normalizeSiteStatus(status); const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) { if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); 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; let detectedPlatform = platform;
if (!detectedPlatform) { if (!detectedPlatform) {
@@ -39,12 +80,14 @@ export async function sitesRoutes(app: FastifyInstance) {
platform: detectedPlatform, platform: detectedPlatform,
apiKey, apiKey,
status: normalizedStatus ?? 'active', status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
}).returning().get(); }).returning().get();
return result; return result;
}); });
// Update a site // 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); const id = parseInt(request.params.id);
if (Number.isNaN(id)) { if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site 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) { if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); 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.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform; if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey; if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (body.status !== undefined) updates.status = normalizedStatus; 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(); updates.updatedAt = new Date().toISOString();
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run(); 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'; const ALGORITHM = 'aes-256-gcm';
function buildKey(): Buffer { 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(); return createHash('sha256').update(secret).digest();
} }
+4
View File
@@ -161,6 +161,8 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
url: siteUrl, url: siteUrl,
platform, platform,
status: 'active', status: 'active',
isPinned: false,
sortOrder: sites.length,
apiKey: null, apiKey: null,
createdAt: toIsoString(item.created_at), createdAt: toIsoString(item.created_at),
updatedAt: toIsoString(item.updated_at), updatedAt: toIsoString(item.updated_at),
@@ -213,6 +215,8 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
unitCost: null, unitCost: null,
valueScore: 0, valueScore: 0,
status: asBoolean(item.disabled, false) ? 'disabled' : 'active', status: asBoolean(item.disabled, false) ? 'disabled' : 'active',
isPinned: false,
sortOrder: accounts.length,
checkinEnabled: asBoolean(checkin.autoCheckInEnabled, true), checkinEnabled: asBoolean(checkin.autoCheckInEnabled, true),
lastCheckinAt: null, lastCheckinAt: null,
lastBalanceRefresh: null, lastBalanceRefresh: null,
+144
View File
@@ -1196,6 +1196,45 @@ body {
font-size: 12px; 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 ===== */ /* ===== Status Badge ===== */
.badge { .badge {
display: inline-flex; display: inline-flex;
@@ -1454,6 +1493,16 @@ textarea {
gap: 8px; 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 Placeholder ===== */
.chart-container { .chart-container {
background: var(--color-bg-card); background: var(--color-bg-card);
@@ -2681,8 +2730,103 @@ textarea {
background: var(--color-bg); 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 ===== */ /* ===== Responsive Adjustments ===== */
@media (max-width: 900px) { @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 { .filter-panel {
display: none; display: none;
} }
+83 -7
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js'; import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
export default function Accounts() { export default function Accounts() {
const [accounts, setAccounts] = useState<any[]>([]); const [accounts, setAccounts] = useState<any[]>([]);
const [sites, setSites] = useState<any[]>([]); const [sites, setSites] = useState<any[]>([]);
const [sortMode, setSortMode] = useState<SortMode>('custom');
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [addMode, setAddMode] = useState<'token' | 'login'>('token'); const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' }); const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
@@ -24,6 +26,11 @@ export default function Accounts() {
}; };
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const sortedAccounts = useMemo(
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
[accounts, sortMode],
);
const handleLoginAdd = async () => { const handleLoginAdd = async () => {
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return; if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
setSaving(true); 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 ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="page-header"> <div className="page-header">
<h2 className="page-title">{tr('账号管理')}</h2> <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']} <button onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')} disabled={actionLoading['checkin-all']}
className="btn btn-soft-primary"> className="btn btn-soft-primary">
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')} {actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
@@ -342,7 +393,7 @@ export default function Accounts() {
{/* Accounts Table */} {/* Accounts Table */}
<div className="card" style={{ overflowX: 'auto' }}> <div className="card" style={{ overflowX: 'auto' }}>
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<table className="data-table"> <table className="data-table accounts-table">
<thead> <thead>
<tr> <tr>
<th>用户名</th> <th>用户名</th>
@@ -351,11 +402,11 @@ export default function Accounts() {
<th>余额</th> <th>余额</th>
<th>已用</th> <th>已用</th>
<th>签到</th> <th>签到</th>
<th style={{ textAlign: 'right' }}>操作</th> <th className="accounts-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <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)}`}> <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 style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
<td> <td>
@@ -427,8 +478,33 @@ export default function Accounts() {
: (a.checkinEnabled ? '开启' : '关闭')} : (a.checkinEnabled ? '开启' : '关闭')}
</button> </button>
</td> </td>
<td style={{ textAlign: 'right' }}> <td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}> <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}`]} <button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
className="btn btn-link btn-link-primary"> className="btn btn-link btn-link-primary">
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'} {actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
+97 -10
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { import {
buildSiteSaveAction, buildSiteSaveAction,
emptySiteForm, emptySiteForm,
@@ -18,6 +20,9 @@ type SiteRow = {
platform?: string; platform?: string;
status?: string; status?: string;
apiKey?: string; apiKey?: string;
isPinned?: boolean;
sortOrder?: number;
totalBalance?: number;
createdAt?: string; createdAt?: string;
}; };
@@ -32,12 +37,15 @@ const platformColors: Record<string, string> = {
export default function Sites() { export default function Sites() {
const [sites, setSites] = useState<SiteRow[]>([]); const [sites, setSites] = useState<SiteRow[]>([]);
const [sortMode, setSortMode] = useState<SortMode>('custom');
const [editor, setEditor] = useState<SiteEditorState | null>(null); const [editor, setEditor] = useState<SiteEditorState | null>(null);
const [form, setForm] = useState<SiteForm>(emptySiteForm()); const [form, setForm] = useState<SiteForm>(emptySiteForm());
const [detecting, setDetecting] = useState(false); const [detecting, setDetecting] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null); const [deleting, setDeleting] = useState<number | null>(null);
const [togglingSiteId, setTogglingSiteId] = 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 toast = useToast();
const isEditing = editor?.mode === 'edit'; const isEditing = editor?.mode === 'edit';
@@ -56,6 +64,11 @@ export default function Sites() {
load(); load();
}, []); }, []);
const sortedSites = useMemo(
() => sortItemsForDisplay(sites, sortMode, (site) => site.totalBalance || 0),
[sites, sortMode],
);
const closeEditor = () => { const closeEditor = () => {
setEditor(null); setEditor(null);
setForm(emptySiteForm()); 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 ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="page-header"> <div className="page-header">
<h2 className="page-title">{tr('站点管理')}</h2> <h2 className="page-title">{tr('站点管理')}</h2>
<button onClick={openAdd} className="btn btn-primary"> <div className="page-actions sites-page-actions">
{isAdding ? '取消' : '+ 添加站点'} <div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
</button> <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> </div>
{editor && ( {editor && (
@@ -265,19 +322,20 @@ export default function Sites() {
<div className="card" style={{ overflowX: 'auto' }}> <div className="card" style={{ overflowX: 'auto' }}>
{sites.length > 0 ? ( {sites.length > 0 ? (
<table className="data-table"> <table className="data-table sites-table">
<thead> <thead>
<tr> <tr>
<th>名称</th> <th>名称</th>
<th>URL</th> <th>URL</th>
<th>总余额</th>
<th>状态</th> <th>状态</th>
<th>平台</th> <th>平台</th>
<th>创建时间</th> <th>创建时间</th>
<th style={{ textAlign: 'right' }}>操作</th> <th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sites.map((site, i) => ( {sortedSites.map((site, i) => (
<tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}> <tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
<td style={{ fontWeight: 600 }}> <td style={{ fontWeight: 600 }}>
<a <a
@@ -292,11 +350,12 @@ export default function Sites() {
{site.name} {site.name}
</a> </a>
</td> </td>
<td style={{ maxWidth: 300 }}> <td className="sites-url-cell" style={{ maxWidth: 300 }}>
<a <a
href={site.url} href={site.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="sites-url-link"
style={{ style={{
fontSize: 12, fontSize: 12,
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
@@ -308,6 +367,9 @@ export default function Sites() {
{site.url} {site.url}
</a> </a>
</td> </td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
</td>
<td> <td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}> <span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
{site.status === 'disabled' ? '禁用' : '启用'} {site.status === 'disabled' ? '禁用' : '启用'}
@@ -335,8 +397,33 @@ export default function Sites() {
{formatDateTimeLocal(site.createdAt)} {formatDateTimeLocal(site.createdAt)}
</a> </a>
</td> </td>
<td style={{ textAlign: 'right' }}> <td className="sites-actions-cell" style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}> <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 <button
onClick={() => openEdit(site)} onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary" className="btn btn-link btn-link-primary"
+61
View File
@@ -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 },
]);
});
});
+76
View File
@@ -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;
}