添加 lucide-svelte 和 svelte-masonry 依赖,更新 svelte.config.js 以支持新组件路径,优化 Docker 构建流程,增加 BottomBar 组件到布局中
Some checks failed
构建并运行 Docker / build (push) Failing after 52s
Some checks failed
构建并运行 Docker / build (push) Failing after 52s
This commit is contained in:
@ -12,11 +12,6 @@ jobs:
|
||||
- name: 释放内存
|
||||
run: docker system prune -a -f
|
||||
|
||||
- name: 设置 Docker Buildx
|
||||
run: |
|
||||
docker buildx create --use --name builder
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: 拉取自定义 checkout action
|
||||
run: git clone http://114.67.155.184:3000//niyyzf/checkout.git ./.actions/checkout
|
||||
|
||||
@ -27,7 +22,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
max-attempts: 5
|
||||
|
||||
- name: 构建并部署
|
||||
- name: 构建 Docker 镜像
|
||||
run: docker build --cache-from type=gha --cache-to type=gha,mode=max -t watch-stock-list:latest .
|
||||
|
||||
- name: 启动/重启服务(Docker Compose)
|
||||
run: |
|
||||
chmod +x build-docker.sh
|
||||
./build-docker.sh
|
||||
docker compose down || true
|
||||
docker compose up -d --force-recreate
|
6
bun.lock
6
bun.lock
@ -5,6 +5,8 @@
|
||||
"name": "watch-stock-list",
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"lucide-svelte": "^0.532.0",
|
||||
"svelte-masonry": "^0.1.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.8.1",
|
||||
@ -450,6 +452,8 @@
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"lucide-svelte": ["lucide-svelte@0.532.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-UMhuyPuzmJB0/2xwdhOW3NRK7vuVpqxIvHiH/bhQTu0vsvRw59YbLC65kS6fierPr7FckFV5Y/y/aUAVUZgGhA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"memoize": ["memoize@10.1.0", "", { "dependencies": { "mimic-function": "^5.0.1" } }, "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg=="],
|
||||
@ -530,6 +534,8 @@
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-Iz8dFXzBNAM7XlEIsUjUGQhbEE+Pvv9odb9+0+ITTgFWZBGeJRRYqHUUglwe2EkLD5LIsQaAc4IUJyvtKuOO5w=="],
|
||||
|
||||
"svelte-masonry": ["svelte-masonry@0.1.5", "", { "peerDependencies": { "svelte": "^4.0.0" } }, "sha512-ZMtALHVbvriGcLODSdoeRaI0SvbmNEW6puG/Zs2tUP7hK0e/TLrxGTAAtNDinI/8DJhUJUCnAsP5xsTmebLwbw=="],
|
||||
|
||||
"svelte-sonner": ["svelte-sonner@1.0.5", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg=="],
|
||||
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
|
||||
|
@ -41,6 +41,8 @@
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.13"
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"lucide-svelte": "^0.532.0",
|
||||
"svelte-masonry": "^0.1.5"
|
||||
}
|
||||
}
|
||||
|
119
src/lib/components/AddReviewCardForm.svelte
Normal file
119
src/lib/components/AddReviewCardForm.svelte
Normal file
@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { REVIEW_TAGS, PRICE_LABELS, type ReviewTag, type PriceLabel, type ReviewCard } from '$lib/types/review';
|
||||
|
||||
let { open = false, onClose, onAdd } = $props<{ open?: boolean; onClose: () => void; onAdd: (card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) => void }>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
function resetForm() {
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
form.tags = [];
|
||||
form.stock = {
|
||||
name: '',
|
||||
code: '',
|
||||
amount: 0,
|
||||
priceGroups: [
|
||||
{ price: 0, label: PRICE_LABELS[0] }
|
||||
]
|
||||
};
|
||||
}
|
||||
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 }))
|
||||
}
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
function handleCancel() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
</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} />
|
||||
</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>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
46
src/lib/components/layout/BottomBar.svelte
Normal file
46
src/lib/components/layout/BottomBar.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Button } from 'ui/button';
|
||||
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';
|
||||
|
||||
// 路由配置
|
||||
const navs = [
|
||||
{ label: '首页', icon: Home, href: '/' },
|
||||
{ label: '搜索', icon: Search, href: '/search' },
|
||||
{ label: '设置', icon: Settings, href: '/settings' },
|
||||
{ label: '股票', icon: Minus, href: '/list' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 w-[95vw] max-w-md mx-auto rounded-2xl bg-background/95 shadow-2xl border border-border/50 flex items-center gap-0 px-0 h-16 z-50 backdrop-blur-md supports-[backdrop-filter]:bg-background/80">
|
||||
{#each navs as nav, i}
|
||||
<div class="flex flex-1 h-full items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href={nav.href}
|
||||
class="group flex flex-col items-center justify-center gap-0.5 w-full h-full rounded-lg border border-transparent transition-all duration-200
|
||||
{get(page).url.pathname === nav.href ? ' text-primary bg-accent/70 shadow-md border-primary/40 ring-2 ring-primary/20' : ' text-muted-foreground hover:bg-accent/60 hover:shadow-md hover:border-primary/40 hover:ring-2 hover:ring-primary/20 focus-visible:ring-2 focus-visible:ring-primary/30'}"
|
||||
aria-label={nav.label}
|
||||
>
|
||||
<svelte:component this={nav.icon} class="w-4 h-4 transition-all duration-200 group-hover:-translate-y-1 group-hover:scale-110 group-hover:text-primary group-hover:drop-shadow {get(page).url.pathname === nav.href ? 'text-primary' : ''}" />
|
||||
<span class="text-xs font-medium transition-all duration-200 group-hover:text-primary group-hover:scale-105 {get(page).url.pathname === nav.href ? 'text-primary' : ''}">{nav.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{#if i === 1}
|
||||
<div class="flex flex-1 h-full items-center justify-center relative">
|
||||
<Button
|
||||
variant="default"
|
||||
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)}
|
||||
>
|
||||
<Plus class="w-8 h-8 text-white transition-transform duration-200 group-hover:rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
33
src/lib/stores/ReviewCardStore.ts
Normal file
33
src/lib/stores/ReviewCardStore.ts
Normal file
@ -0,0 +1,33 @@
|
||||
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();
|
32
src/lib/types/review.ts
Normal file
32
src/lib/types/review.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// 复盘卡片标签类型
|
||||
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 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;
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import BottomBar from '../lib/components/layout/BottomBar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
<BottomBar />
|
||||
|
117
src/routes/list/+page.svelte
Normal file
117
src/routes/list/+page.svelte
Normal file
@ -0,0 +1,117 @@
|
||||
|
||||
<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 BottomBar from '$lib/components/layout/BottomBar.svelte';
|
||||
import { reviewCardStore, addReviewCardStore } from '@/lib/stores/ReviewCardStore';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
form.tags = [];
|
||||
form.stock = {
|
||||
name: '',
|
||||
code: '',
|
||||
amount: 0,
|
||||
priceGroups: [
|
||||
{ price: 0, label: PRICE_LABELS[0] }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<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}
|
||||
</Masonry>
|
||||
</div>
|
||||
|
||||
<AddReviewCardForm open={$addReviewCardStore} onAdd={handleAdd} onClose={() => addReviewCardStore.set(false)} />
|
||||
|
||||
<BottomBar />
|
||||
|
||||
<style>
|
||||
:global(html) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tw-prose-invert) var(--background);
|
||||
}
|
||||
:global(*::-webkit-scrollbar) {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
:global(*::-webkit-scrollbar-thumb) {
|
||||
background: rgba(120,120,120,0.18);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
:global(*:hover::-webkit-scrollbar-thumb) {
|
||||
background: rgba(120,120,120,0.32);
|
||||
}
|
||||
:global(*::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
:global(.masonry) {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
</style>
|
@ -13,7 +13,8 @@ const config = {
|
||||
out: 'build'
|
||||
}),
|
||||
alias: {
|
||||
'@/*': './src/*'
|
||||
'@/*': './src/*',
|
||||
'ui/*': './src/lib/components/ui/*'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user