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
+79 -33
View File
@@ -22,14 +22,39 @@ type AccountWithSiteRow = {
sites: typeof schema.sites.$inferSelect;
};
type AccountHealthRefreshResult = {
accountId: number;
username: string | null;
siteName: string;
type AccountHealthRefreshResult = {
accountId: number;
username: string | null;
siteName: string;
status: 'success' | 'failed' | 'skipped';
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[]) {
return {
@@ -239,16 +264,18 @@ export async function accountsRoutes(app: FastifyInstance) {
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
} else {
const created = db.insert(schema.accounts).values({
siteId,
username,
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
extraConfig,
}).returning().get();
accountId = created.id;
}
const created = db.insert(schema.accounts).values({
siteId,
username,
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
extraConfig,
isPinned: false,
sortOrder: getNextAccountSortOrder(),
}).returning().get();
accountId = created.id;
}
const result = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
if (!result) {
@@ -428,14 +455,16 @@ export async function accountsRoutes(app: FastifyInstance) {
extraConfig = mergeAccountExtraConfig(undefined, { platformUserId: resolvedPlatformUserId });
}
const result = db.insert(schema.accounts).values({
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
}).returning().get();
const result = db.insert(schema.accounts).values({
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: getNextAccountSortOrder(),
}).returning().get();
if (apiToken) {
try {
@@ -469,15 +498,32 @@ export async function accountsRoutes(app: FastifyInstance) {
});
// Update an account
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request) => {
const id = parseInt(request.params.id);
const body = request.body as Record<string, unknown>;
const updates: any = {};
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
if (body[key] !== undefined) updates[key] = body[key];
}
updates.updatedAt = new Date().toISOString();
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
const id = parseInt(request.params.id);
const body = request.body as Record<string, unknown>;
const updates: any = {};
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
if (body[key] !== undefined) updates[key] = body[key];
}
if (body.isPinned !== undefined) {
const normalizedPinned = normalizePinnedFlag(body.isPinned);
if (normalizedPinned === null) {
return reply.code(400).send({ message: 'Invalid isPinned value. Expected boolean.' });
}
updates.isPinned = normalizedPinned;
}
if (body.sortOrder !== undefined) {
const normalizedSortOrder = normalizeSortOrder(body.sortOrder);
if (normalizedSortOrder === null) {
return reply.code(400).send({ message: 'Invalid sortOrder value. Expected non-negative integer.' });
}
updates.sortOrder = normalizedSortOrder;
}
updates.updatedAt = new Date().toISOString();
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try {
+57 -4
View File
@@ -11,19 +11,60 @@ function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
return null;
}
function normalizePinnedFlag(input: unknown): boolean | null {
if (input === undefined || input === null) return null;
if (typeof input === 'boolean') return input;
if (typeof input === 'number') return input !== 0;
if (typeof input === 'string') {
const normalized = input.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
}
return null;
}
function normalizeSortOrder(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null;
const parsed = Number.parseInt(String(input), 10);
if (!Number.isFinite(parsed)) return null;
return Math.max(0, parsed);
}
export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
return db.select().from(schema.sites).all();
const siteRows = db.select().from(schema.sites).all();
const accountRows = db.select().from(schema.accounts).all();
const totalBalanceBySiteId: Record<number, number> = {};
for (const account of accountRows) {
totalBalanceBySiteId[account.siteId] = (totalBalanceBySiteId[account.siteId] || 0) + (account.balance || 0);
}
return siteRows.map((site) => ({
...site,
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
}));
});
// Add a site
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, status } = request.body;
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, status, isPinned, sortOrder } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
const normalizedPinned = normalizePinnedFlag(isPinned);
if (isPinned !== undefined && normalizedPinned === null) {
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
}
const normalizedSortOrder = normalizeSortOrder(sortOrder);
if (sortOrder !== undefined && normalizedSortOrder === null) {
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
}
const existingSites = db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
let detectedPlatform = platform;
if (!detectedPlatform) {
@@ -39,12 +80,14 @@ export async function sitesRoutes(app: FastifyInstance) {
platform: detectedPlatform,
apiKey,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
}).returning().get();
return result;
});
// Update a site
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string } }>('/api/sites/:id', async (request, reply) => {
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
@@ -61,12 +104,22 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
const normalizedPinned = normalizePinnedFlag(body.isPinned);
if (body.isPinned !== undefined && normalizedPinned === null) {
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
}
const normalizedSortOrder = normalizeSortOrder(body.sortOrder);
if (body.sortOrder !== undefined && normalizedSortOrder === null) {
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
}
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (body.status !== undefined) updates.status = normalizedStatus;
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
updates.updatedAt = new Date().toISOString();
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();