3 Commits

10 changed files with 586 additions and 240 deletions

View File

@ -10,8 +10,9 @@ WORKDIR /app
# 安装依赖
COPY package.json ./
# 使用 npm 缓存来加速构建
RUN npm config set cache /tmp/npm-cache && \
npm install --cache /tmp/npm-cache --prefer-offline --no-audit --no-fund --production=false
#换源
RUN npm config set registry https://registry.npm.taobao.org
RUN npm install --prefer-offline --no-audit --no-fund --production=false --legacy-peer-deps
# 构建阶段
FROM base AS builder

View File

@ -5,6 +5,7 @@
"name": "watch-stock-list",
"dependencies": {
"@sveltejs/adapter-node": "^5.2.13",
"idb": "^8.0.3",
"lucide-svelte": "^0.532.0",
"svelte-masonry": "^0.1.5",
},
@ -398,6 +399,8 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],

View File

@ -42,6 +42,7 @@
},
"dependencies": {
"@sveltejs/adapter-node": "^5.2.13",
"idb": "^8.0.3",
"lucide-svelte": "^0.532.0",
"svelte-masonry": "^0.1.5"
}

View File

@ -1,119 +1,338 @@
<script lang="ts">
import { REVIEW_TAGS, PRICE_LABELS, type ReviewTag, type PriceLabel, type ReviewCard } from '$lib/types/review';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Plus, X } from 'lucide-svelte';
import type { StockInfo } from '$lib/types/review';
import { page } from '$app/stores';
let { open = false, onClose, onAdd } = $props<{ open?: boolean; onClose: () => void; onAdd: (card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) => void }>();
export let open = false;
export let onClose: () => void;
export let onAdd: (stockInfo: StockInfo) => void;
let form = $state({
title: '',
content: '',
tags: [] as ReviewTag[],
stock: {
// 默认标签
const defaultLabels = [
{ label: '买入价', key: 'buy' },
{ label: '目标价', key: 'target' },
{ label: '止损价', key: 'stop' }
];
let form: StockInfo = {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] as PriceLabel }
]
}
});
function addPriceGroup() {
form.stock.priceGroups.push({ price: 0, label: PRICE_LABELS[0] });
}
function removePriceGroup(idx: number) {
if (form.stock.priceGroups.length > 1) {
form.stock.priceGroups.splice(idx, 1);
}
}
function resetForm() {
form.title = '';
form.content = '';
form.tags = [];
form.stock = {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] }
]
tags: [],
priceGroups: [],
createdAt: ''
};
}
function handleSubmit() {
if (!form.title.trim() || !form.content.trim()) return;
onAdd({
title: form.title,
content: form.content,
tags: [...form.tags],
stock: {
name: form.stock.name,
code: form.stock.code,
amount: form.stock.amount,
priceGroups: form.stock.priceGroups.map(pg => ({ ...pg }))
// 保证 price 始终为 number 类型
let addedPriceGroups: { label: string; price: number }[] = [];
let customLabelInput = '';
let customPriceInput: string = '';
let customLabelError = '';
let nameError = '';
let codeError = '';
let tagInput = '';
let tagInputRef: any = null;
// 从URL参数填充表单数据
function fillFormFromUrlParams() {
const urlParams = $page.url.searchParams;
const code = urlParams.get('code');
const name = urlParams.get('name');
const tags = urlParams.get('tags');
if (code) {
form.code = code;
}
});
resetForm();
onClose();
}
function handleCancel() {
resetForm();
onClose();
}
if (name) {
form.name = name;
}
if (tags) {
// 支持逗号分隔的标签
const tagArray = tags.split(',').map(tag => tag.trim()).filter(tag => tag);
form.tags = [...form.tags, ...tagArray];
}
}
function validate() {
const trimmedName = form.name.trim();
const trimmedCode = form.code.trim();
nameError = trimmedName ? '' : '股票名称不能为空';
codeError = trimmedCode ? '' : '股票代码不能为空';
customLabelError = '';
// 只有当自定义标签有输入时才验证价格
if (customLabelInput.trim() && customPriceInput === '') {
customLabelError = '请输入价格';
}
return !nameError && !codeError && !customLabelError;
}
function addTag() {
const tag = tagInput.trim();
if (tag && !form.tags.includes(tag)) {
form.tags = [...form.tags, tag];
}
tagInput = '';
if (tagInputRef && typeof tagInputRef.focus === 'function') tagInputRef.focus();
}
function removeTag(idx: number) {
form.tags = form.tags.filter((_, i) => i !== idx);
}
// 添加自带标签
function addDefaultLabel(label: string) {
if (!addedPriceGroups.some(pg => pg.label === label)) {
addedPriceGroups = [...addedPriceGroups, { label, price: 0 }];
}
}
// 添加自定义标签
function addCustomPriceGroup() {
const label = customLabelInput.trim();
const price = Number(customPriceInput);
if (!label) {
customLabelError = '请输入标签名';
return;
}
if (customPriceInput === '' || isNaN(price)) {
customLabelError = '请输入价格';
return;
}
if (
defaultLabels.some(l => l.label === label) ||
addedPriceGroups.some(pg => pg.label === label)
) {
customLabelError = '标签名已存在';
return;
}
addedPriceGroups = [...addedPriceGroups, { label, price }];
customLabelInput = '';
customPriceInput = '';
customLabelError = '';
}
// 删除标签
function removePriceGroup(idx: number) {
addedPriceGroups = addedPriceGroups.filter((_, i) => i !== idx);
}
function resetForm() {
form = {
name: '',
code: '',
tags: [],
priceGroups: [],
createdAt: ''
};
addedPriceGroups = [];
customLabelInput = '';
customPriceInput = '';
customLabelError = '';
nameError = '';
codeError = '';
tagInput = '';
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!validate()) return;
const trimmedName = form.name.trim();
const trimmedCode = form.code.trim();
// 确保 code 不为空
if (!trimmedCode) {
codeError = '股票代码不能为空';
return;
}
try {
const stockData = {
name: trimmedName,
code: trimmedCode,
tags: [...form.tags],
priceGroups: addedPriceGroups.filter(pg => !isNaN(pg.price) && pg.price > 0),
createdAt: new Date().toISOString()
};
console.log('表单提交的股票数据:', stockData);
await onAdd(stockData);
resetForm();
onClose();
} catch (error) {
console.error('表单提交错误:', error);
// 如果是重复股票的错误,显示友好提示
if (error instanceof Error && error.message.includes('已存在')) {
nameError = '该股票已存在';
codeError = '该股票已存在';
}
}
}
function handleCancel() {
resetForm();
onClose();
}
function handleTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
}
}
// 当对话框打开时检查URL参数并填充表单
$: if (open) {
fillFormFromUrlParams();
setTimeout(() => {
document.getElementById('stock-name')?.focus();
}, 100);
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-xl p-6 w-full max-w-md relative animate-in fade-in-0 zoom-in-95">
<h2 class="text-lg font-bold mb-4">添加复盘卡片</h2>
<form on:submit|preventDefault={handleSubmit}>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">标题</label>
<input class="input input-bordered w-full" bind:value={form.title} required />
</div>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">内容</label>
<textarea class="input input-bordered w-full min-h-[80px]" bind:value={form.content} required />
</div>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">标签</label>
<div class="flex flex-wrap gap-2">
{#each REVIEW_TAGS as tag}
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" bind:group={form.tags} value={tag} />
<span class="text-xs">{tag}</span>
</label>
{/each}
</div>
</div>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">股票信息</label>
<div class="flex gap-2 mb-2">
<input class="input input-bordered flex-1" placeholder="名称" bind:value={form.stock.name} />
<input class="input input-bordered flex-1" placeholder="代码" bind:value={form.stock.code} />
<input class="input input-bordered w-20" type="number" min="0" placeholder="数量" bind:value={form.stock.amount} />
<Dialog bind:open>
<DialogContent class="max-w-xl w-full rounded-md p-0 bg-background shadow-2xl">
<DialogHeader class="px-8 pt-8 pb-3">
<DialogTitle class="text-2xl font-bold tracking-tight mb-1">添加股票信息</DialogTitle>
<DialogDescription class="text-base text-muted-foreground mt-1 mb-2">
填写股票基本信息和价格标签(可选)。
</DialogDescription>
</DialogHeader>
<form class="space-y-5 px-8 pb-8 pt-2 font-sans" onsubmit={handleSubmit}>
<!-- 股票信息 -->
<div>
<div class="text-lg font-semibold mb-3 tracking-tight">股票信息</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label for="stock-name" class="mb-1 block text-sm text-muted-foreground font-medium">名称</Label>
<Input
id="stock-name"
bind:value={form.name}
placeholder="股票名称"
class="h-10 w-full rounded-md px-4 text-sm placeholder:text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-primary border border-input"
onblur={() => (nameError = form.name.trim() ? '' : '股票名称不能为空')}
autocomplete="off"
/>
{#if nameError}
<span class="text-xs text-red-500 mt-1 block">{nameError}</span>
{/if}
</div>
<div>
<label class="block text-xs font-medium mb-1">价格组</label>
{#each form.stock.priceGroups as pg, i}
<div class="flex items-center gap-2 mb-1">
<select class="input input-bordered w-24" bind:value={pg.label}>
{#each PRICE_LABELS as label}
<option value={label}>{label}</option>
{/each}
</select>
<input class="input input-bordered w-24" type="number" min="0" placeholder="价格" bind:value={pg.price} />
{#if form.stock.priceGroups.length > 1}
<button type="button" class="btn btn-xs btn-error" on:click={() => removePriceGroup(i)}>移除</button>
{/if}
</div>
{/each}
<button type="button" class="btn btn-xs btn-primary mt-1" on:click={addPriceGroup}>添加价格组</button>
<Label for="stock-code" class="mb-1 block text-sm text-muted-foreground font-medium">代码</Label>
<Input
id="stock-code"
bind:value={form.code}
placeholder="股票代码"
class="h-10 w-full rounded-md px-4 text-sm placeholder:text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-primary border border-input"
onblur={() => (codeError = form.code.trim() ? '' : '股票代码不能为空')}
autocomplete="off"
/>
{#if codeError}
<span class="text-xs text-red-500 mt-1 block">{codeError}</span>
{/if}
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" class="btn btn-ghost" on:click={handleCancel}>取消</button>
<button type="submit" class="btn btn-primary">添加</button>
<div class="mt-4">
<Label for="stock-tags" class="mb-1 block text-sm text-muted-foreground font-medium">股票标签</Label>
<div class="flex flex-wrap gap-2 items-center mb-1">
{#each form.tags as tag, i}
<span class="inline-flex items-center px-2 py-0.5 bg-muted rounded-md shadow-sm text-sm border border-muted-foreground/10 transition hover:bg-primary/20 hover:text-primary-foreground cursor-pointer">
{tag}
<button type="button" class="ml-1 hover:text-destructive focus:outline-none" tabindex="-1" onclick={() => removeTag(i)}>
<X class="w-3 h-3" />
</button>
</span>
{/each}
</div>
<div class="flex items-center gap-2">
<Input
id="stock-tags"
bind:this={tagInputRef}
class="w-40 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
placeholder="输入标签后回车"
bind:value={tagInput}
onkeydown={handleTagKeydown}
onblur={addTag}
autocomplete="off"
/>
<span class="text-xs text-muted-foreground">按回车或逗号添加标签</span>
</div>
</div>
</form>
</div>
</div>
{/if}
</div>
<div class="border-t border-muted-foreground/10 my-3"></div>
<!-- 价格标签(可选) -->
<div>
<div class="text-lg font-semibold mb-2 tracking-tight">价格标签 <span class="text-xs text-muted-foreground">(完全可选)</span></div>
<div class="flex flex-wrap gap-2 mb-2 items-center">
{#each defaultLabels as l}
<Button
type="button"
size="sm"
variant={addedPriceGroups.some(pg => pg.label === l.label) ? 'secondary' : 'outline'}
class="rounded-md hover:bg-primary/10 hover:text-primary-foreground transition-colors font-medium h-8 px-3"
disabled={addedPriceGroups.some(pg => pg.label === l.label)}
onclick={() => addDefaultLabel(l.label)}
>
{#if addedPriceGroups.some(pg => pg.label === l.label)}
已添加
{:else}
<Plus class="w-4 h-4 mr-1" />{l.label}
{/if}
</Button>
{/each}
</div>
<div class="space-y-2">
{#each addedPriceGroups as pg, i}
<div class="flex items-center gap-3">
<span class="inline-flex items-center h-8 px-3 bg-muted rounded-md shadow-sm text-sm font-medium leading-none border border-muted-foreground/10 transition hover:bg-primary/20 hover:text-primary-foreground cursor-pointer">
{pg.label}
<button type="button" class="ml-1 hover:text-destructive focus:outline-none" tabindex="-1" onclick={() => removePriceGroup(i)}>
<X class="w-3 h-3" />
</button>
</span>
<Input
type="number"
min="0"
placeholder="请输入价格"
class="w-32 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
value={pg.price === 0 ? '' : pg.price}
oninput={e => pg.price = Number((e.target as HTMLInputElement).value)}
autocomplete="off"
/>
</div>
{/each}
</div>
<!-- 自定义标签输入 -->
<div class="flex items-center gap-2 mt-2">
<Input
class="w-32 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
placeholder="自定义标签名"
bind:value={customLabelInput}
autocomplete="off"
/>
<Input
type="number"
min="0"
class="w-32 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
placeholder="请输入价格"
bind:value={customPriceInput}
autocomplete="off"
/>
<Button type="button" size="sm" class="h-8 rounded-md font-medium" onclick={addCustomPriceGroup}>
<Plus class="h-4 w-4" /> 添加
</Button>
</div>
{#if customLabelError}
<span class="text-xs text-red-500 mt-1 block">{customLabelError}</span>
{/if}
</div>
<DialogFooter class="flex flex-row justify-end gap-4 pt-6 pb-2 px-0">
<Button variant="outline" type="button" class="min-w-[90px] h-10 rounded-md font-medium" onclick={handleCancel}>取消</Button>
<Button
type="submit"
class="min-w-[120px] h-10 rounded-md text-base font-semibold"
disabled={!form.name.trim() || !form.code.trim() || !validate()}
>
添加股票
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,54 @@
<script lang="ts">
import type { StockInfo } from '$lib/types/review';
import { X, Clock } from 'lucide-svelte';
export let stock: StockInfo;
export let onDelete: () => void;
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-md overflow-hidden border border-border p-4">
<div class="flex justify-between items-start mb-2">
<div class="flex-1">
<div class="font-semibold text-lg">{stock.name} <span class="text-sm text-muted-foreground">({stock.code})</span></div>
{#if stock.createdAt}
<div class="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock class="w-3 h-3" />
<span>{formatDate(stock.createdAt)}</span>
</div>
{/if}
</div>
{#if onDelete}
<button class="text-destructive hover:bg-destructive/10 rounded p-1 ml-2" on:click={onDelete} title="删除">
<X class="w-4 h-4" />
</button>
{/if}
</div>
{#if stock.tags && stock.tags.length}
<div class="flex flex-wrap gap-2 mb-2">
{#each stock.tags as tag}
<span class="inline-block px-2 py-0.5 bg-muted rounded text-xs">{tag}</span>
{/each}
</div>
{/if}
{#if stock.priceGroups && stock.priceGroups.length}
<div class="space-y-1">
{#each stock.priceGroups as group}
<div class="flex items-center gap-2">
<span class="inline-block px-2 py-0.5 bg-gray-100 rounded text-xs">{group.label}</span>
<span class="text-sm">{group.price}</span>
</div>
{/each}
</div>
{/if}
</div>

View File

@ -3,7 +3,7 @@
import { Home, Plus, Search, Settings, Minus } from 'lucide-svelte';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { addReviewCardStore } from '@/lib/stores/ReviewCardStore';
import { addStockStore } from '$lib/stores/StockStore';
// 路由配置
const navs = [
@ -36,7 +36,7 @@
size="icon"
class="rounded-full w-16 h-16 shadow-2xl border-4 border-background bg-gradient-to-br from-primary via-primary to-primary/90 flex items-center justify-center transition-all duration-300 hover:scale-110 hover:shadow-[0_20px_40px_rgba(0,0,0,0.3)] active:scale-95 focus-visible:ring-4 focus-visible:ring-primary/20 absolute left-1/2 -translate-x-1/2 -top-6 z-10"
aria-label="添加"
onclick={() => addReviewCardStore.set(true)}
onclick={() => addStockStore.set(true)}
>
<Plus class="w-8 h-8 text-white transition-transform duration-200 group-hover:rotate-90" />
</Button>

View File

@ -1,33 +0,0 @@
import { writable } from 'svelte/store';
import type { ReviewCard } from '../types/review';
// 控制添加卡片弹窗
export const addReviewCardStore = writable(false);
// 复盘卡片列表 store
function createReviewCardStore() {
const { subscribe, set, update } = writable<ReviewCard[]>([]);
return {
subscribe,
set,
reset: () => set([]),
add: (card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) =>
update(cards => [
...cards,
{
...card,
id: crypto.randomUUID(),
starred: false,
createdAt: new Date(),
updatedAt: new Date()
}
]),
remove: (id: string) => update(cards => cards.filter(card => card.id !== id)),
toggleStar: (id: string) => update(cards => cards.map(card => card.id === id ? { ...card, starred: !card.starred } : card)),
updateCard: (id: string, data: Partial<Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt'>>) =>
update(cards => cards.map(card => card.id === id ? { ...card, ...data, updatedAt: new Date() } : card))
};
}
export const reviewCardStore = createReviewCardStore();

View File

@ -0,0 +1,166 @@
import { writable } from 'svelte/store';
import { openDB, type IDBPDatabase } from 'idb';
import type { StockInfo } from '$lib/types/review';
// 数据库配置
const DB_NAME = 'StockWatchDB';
const DB_VERSION = 1;
const STORE_NAME = 'stocks';
// 初始化数据库
async function initDB() {
console.log('初始化数据库...');
const db = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
console.log('数据库升级,创建对象存储...');
// 创建 stocks 存储,使用 code 作为主键
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'code' });
// 创建索引以便按时间排序
store.createIndex('createdAt', 'createdAt', { unique: false });
console.log('对象存储创建成功keyPath:', 'code');
}
},
});
console.log('数据库初始化完成');
return db;
}
// 数据库操作函数
async function loadStocksFromDB(): Promise<StockInfo[]> {
try {
const db = await initDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const stocks = await store.getAll();
return stocks;
} catch (error) {
console.error('加载股票数据失败:', error);
return [];
}
}
async function saveStockToDB(stock: StockInfo): Promise<void> {
try {
console.log('准备保存股票数据:', stock);
// 验证股票代码是否存在且不为空
if (!stock.code || stock.code.trim() === '') {
console.error('股票代码为空:', stock);
throw new Error('股票代码不能为空');
}
const db = await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
// 检查是否已存在相同代码的股票
const existingStock = await store.get(stock.code);
if (existingStock) {
throw new Error(`股票代码 ${stock.code} 已存在`);
}
console.log('添加到数据库:', stock);
await store.add(stock);
await tx.done;
console.log('股票数据保存成功');
} catch (error) {
console.error('保存股票数据失败:', error);
console.error('股票数据:', stock);
throw error;
}
}
async function removeStockFromDB(code: string): Promise<void> {
try {
const db = await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
await store.delete(code);
await tx.done;
} catch (error) {
console.error('删除股票数据失败:', error);
throw error;
}
}
async function clearAllStocksFromDB(): Promise<void> {
try {
const db = await initDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
await store.clear();
await tx.done;
} catch (error) {
console.error('清空股票数据失败:', error);
throw error;
}
}
// 创建增强的 store
function createStockStore() {
const { subscribe, set, update } = writable<StockInfo[]>([]);
// 初始化时从数据库加载数据
let initialized = false;
return {
subscribe,
// 初始化 store
async init() {
if (!initialized) {
const stocks = await loadStocksFromDB();
set(stocks);
initialized = true;
}
},
// 添加股票
async add(stock: StockInfo) {
try {
await saveStockToDB(stock);
update(list => [...list, stock]);
} catch (error) {
console.error('添加股票失败:', error);
throw error;
}
},
// 删除股票(通过股票代码)
async remove(code: string) {
try {
await removeStockFromDB(code);
update(list => list.filter(stock => stock.code !== code));
} catch (error) {
console.error('删除股票失败:', error);
throw error;
}
},
// 重置所有数据
async reset() {
try {
await clearAllStocksFromDB();
set([]);
} catch (error) {
console.error('重置数据失败:', error);
throw error;
}
},
// 直接设置数据(用于测试或导入)
set,
// 更新数据(用于编辑)
update
};
}
export const stockStore = createStockStore();
export const addStockStore = writable(false);
// 在浏览器环境中自动初始化
if (typeof window !== 'undefined') {
stockStore.init();
}

View File

@ -1,32 +1,14 @@
// 复盘卡片标签类型
export const REVIEW_TAGS = ['情绪', '策略', '失误', '复盘'] as const;
export type ReviewTag = typeof REVIEW_TAGS[number];
// 股票信息类型和自定义价格标签类型
// 价格组类型
export const PRICE_LABELS = ['买入价', '止损价', '目标价', '卖出价'] as const;
export type PriceLabel = typeof PRICE_LABELS[number];
type PriceGroup = {
price: number;
label: PriceLabel;
export type CustomPriceGroup = {
label: string; // 价格标签名
price: number; // 价格
};
// 股票信息类型
export interface StockInfo {
name: string;
code: string;
amount: number;
priceGroups: PriceGroup[];
}
// 复盘卡片类型
export interface ReviewCard {
id: string;
title: string;
content: string;
tags: ReviewTag[];
stock: StockInfo;
starred: boolean;
createdAt: Date;
updatedAt: Date;
name: string; // 股票名称
code: string; // 股票代码(唯一标识符)
tags: string[]; // 股票标签
priceGroups: CustomPriceGroup[]; // 价格标签+价格数组
createdAt: string; // 添加时间
}

View File

@ -1,91 +1,43 @@
<script lang="ts">
// @ts-expect-error: svelte-masonry 没有类型声明
import Masonry from 'svelte-masonry';
import { REVIEW_TAGS, PRICE_LABELS, type ReviewTag, type PriceLabel, type ReviewCard } from '$lib/types/review';
import AddReviewCardForm from '$lib/components/AddReviewCardForm.svelte';
import StockCard from '$lib/components/StockCard.svelte';
import BottomBar from '$lib/components/layout/BottomBar.svelte';
import { reviewCardStore, addReviewCardStore } from '@/lib/stores/ReviewCardStore';
import { stockStore, addStockStore } from '$lib/stores/StockStore';
import type { StockInfo } from '$lib/types/review';
import { toast } from 'svelte-sonner';
let showForm = $state(false);
// let openAddForm = $state(false); // 移除 openAddForm 相关代码,表单 open={addReviewCardSignal}
let form = $state({
title: '',
content: '',
tags: [] as ReviewTag[],
stock: {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] as PriceLabel }
]
}
});
function addPriceGroup() {
form.stock.priceGroups.push({ price: 0, label: PRICE_LABELS[0] });
}
function removePriceGroup(idx: number) {
if (form.stock.priceGroups.length > 1) {
form.stock.priceGroups.splice(idx, 1);
async function handleAdd(stock: StockInfo) {
try {
console.log('页面组件接收到的股票数据:', stock);
await stockStore.add(stock);
} catch (error) {
console.error('添加股票失败:', error);
toast.error('添加股票失败');
}
}
function resetForm() {
form.title = '';
form.content = '';
form.tags = [];
form.stock = {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] }
]
};
async function handleDelete(stock: StockInfo) {
try {
await stockStore.remove(stock.code);
} catch (error) {
console.error('删除股票失败:', error);
toast.error('删除股票失败');
}
}
function handleAddCard() {
// 简单校验
if (!form.title.trim() || !form.content.trim()) return;
reviewCardStore.add({
title: form.title,
content: form.content,
tags: [...form.tags],
stock: {
name: form.stock.name,
code: form.stock.code,
amount: form.stock.amount,
priceGroups: form.stock.priceGroups.map(pg => ({ ...pg }))
}
});
resetForm();
showForm = false;
}
function handleAdd(card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) {
reviewCardStore.add(card);
}
</script>
<div class="mx-auto p-4">
<h1 class="text-2xl font-bold mb-6">瀑布流列表演示</h1>
<h1 class="text-2xl font-bold mb-6">股票列表</h1>
<Masonry columns="2" gap="16">
{#each $reviewCardStore as card (card.id)}
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-md overflow-hidden mb-4 border border-border">
<div class="p-3">
<div class="font-semibold text-base mb-1">{card.title}</div>
</div>
</div>
{#each $stockStore as stock, idx (stock.name + stock.code)}
<StockCard {stock} onDelete={() => handleDelete(stock)} />
{/each}
</Masonry>
</div>
<AddReviewCardForm open={$addReviewCardStore} onAdd={handleAdd} onClose={() => addReviewCardStore.set(false)} />
<AddReviewCardForm open={$addStockStore} onAdd={handleAdd} onClose={() => addStockStore.set(false)} />
<BottomBar />
<style>
@ -114,4 +66,5 @@
margin-left: 0 !important;
margin-right: 0 !important;
}
</style>
</style>