forked from boos/BoosAPI
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
|
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` |
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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