feat(api): 接入 yahoo-finance2 构建股票自动行情引擎,并实装一键同步按钮

This commit is contained in:
2026-04-28 08:56:46 +08:00
parent effa84fe14
commit ba6a922f2c
6 changed files with 347 additions and 111 deletions
+36
View File
@@ -0,0 +1,36 @@
'use server';
import yahooFinance from 'yahoo-finance2';
import { db } from '@/db';
import { assets } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
export async function syncAllStockPrices() {
const stockAssets = await db
.select()
.from(assets)
.where(eq(assets.type, 'STOCK'));
let successCount = 0;
for (const asset of stockAssets) {
try {
const quote = await yahooFinance.quote(asset.symbol);
if (quote && quote.regularMarketPrice !== undefined) {
await db
.update(assets)
.set({ latestPrice: quote.regularMarketPrice.toString() })
.where(eq(assets.id, asset.id));
successCount++;
}
} catch (error) {
console.error(`Failed to fetch price for ${asset.symbol}:`, error);
}
}
revalidatePath('/dashboard');
revalidatePath('/dashboard/assets');
return { success: true, count: successCount };
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { useState, useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
import { syncAllStockPrices } from '@/actions/market';
export function SyncButton() {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
await syncAllStockPrices();
});
}
return (
<Button onClick={handleClick} disabled={isPending}>
<RefreshCw className={`h-4 w-4 mr-2 ${isPending ? 'animate-spin' : ''}`} />
{isPending ? '同步中...' : '同步股票行情'}
</Button>
);
}
@@ -1,101 +0,0 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { RefreshCw } from 'lucide-react';
import { updateAssetPrice } from '@/actions/asset';
const updatePriceSchema = z.object({
newPrice: z.string().min(1, '價格不能為空'),
});
type UpdatePriceForm = z.infer<typeof updatePriceSchema>;
interface UpdatePriceDialogProps {
assetId: string;
currentPrice: string;
}
export function UpdatePriceDialog({ assetId, currentPrice }: UpdatePriceDialogProps) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const form = useForm<UpdatePriceForm>({
resolver: zodResolver(updatePriceSchema),
defaultValues: {
newPrice: currentPrice,
},
});
function onSubmit(values: UpdatePriceForm) {
startTransition(async () => {
await updateAssetPrice({ assetId, newPrice: values.newPrice });
setOpen(false);
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<RefreshCw className="h-3 w-3 mr-1" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="newPrice"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="輸入價格數值" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '確認更新'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}