new
Some checks failed
Deploy Docker Image / build-and-deploy (push) Has been cancelled

This commit is contained in:
jeremygan2021
2026-02-27 17:16:44 +08:00
parent d1e78559bc
commit 5826f5ff86
5 changed files with 172 additions and 207 deletions

View File

@@ -8,35 +8,39 @@ on:
jobs:
build-and-deploy:
runs-on: ubuntu
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: https://gitea.com/actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest
run: |
docker build -t ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest .
docker push ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest
- name: Deploy to Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_KEY }}
script: |
docker pull ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest
docker stop wx-pyq || true
docker rm wx-pyq || true
docker run -d --name wx-pyq -p 80:80 ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest
run: |
# Install SSH client
if command -v apk > /dev/null; then
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
apk add --no-cache openssh-client
elif command -v apt-get > /dev/null; then
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
apt-get update && apt-get install -y openssh-client
fi
# Setup SSH key
mkdir -p ~/.ssh
echo "${{ secrets.SERVER_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Deploy
ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ${{ secrets.SERVER_USERNAME }}@${{ secrets.SERVER_HOST }} "
docker pull ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest && \
(docker stop wx-pyq || true) && \
(docker rm wx-pyq || true) && \
docker run -d --name wx-pyq -p 3321:80 ${{ secrets.DOCKER_USERNAME }}/wx-pyq:latest
"

View File

@@ -3,6 +3,8 @@ FROM node:18-alpine as builder
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com
COPY package*.json ./
RUN npm ci

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Button, Toast } from 'antd-mobile';
import { Copy, Quote, Sparkles } from 'lucide-react';
import { motion } from 'framer-motion';
interface CopyCardProps {
result: string;
loading: boolean;
scenario?: string;
}
/**
* 分离出的文案显示组件
* 负责文案的渲染、流式动画效果以及复制功能
*/
export const CopyCard: React.FC<CopyCardProps> = ({ result, loading, scenario }) => {
const handleCopy = () => {
if (!result) return;
navigator.clipboard.writeText(result).then(() => {
Toast.show({ content: '已复制文案', icon: 'success' });
});
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-slate-900 rounded-3xl p-6 md:p-8 shadow-xl border border-slate-100 dark:border-slate-800 relative mb-6"
>
{scenario && (
<div className="absolute -top-3 left-6 z-10">
<span className="bg-indigo-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow-lg flex items-center gap-1">
<Sparkles size={12} />
{scenario}
</span>
</div>
)}
<Quote className="w-10 h-10 text-indigo-100 dark:text-indigo-900/30 mb-4 -scale-x-100" />
<div className="text-lg md:text-xl leading-relaxed text-slate-700 dark:text-slate-200 whitespace-pre-wrap font-sans min-h-[120px]">
{result}
{loading && (
<span className="inline-block w-2 h-6 ml-1 animate-pulse bg-indigo-500 align-middle" />
)}
</div>
<div className="mt-6 flex justify-end border-t border-slate-50 dark:border-slate-800 pt-4">
<Button
size="middle"
color="primary"
fill="solid"
onClick={handleCopy}
disabled={loading || !result}
className="rounded-xl px-6 bg-indigo-600 hover:bg-indigo-700 border-none flex items-center active:scale-95 transition-transform"
>
<Copy size={18} className="mr-2" />
</Button>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { motion } from 'framer-motion';
interface NineGridProps {
images: string[];
}
/**
* 专门用于 9 宫格布局的显示组件
*/
export const NineGrid: React.FC<NineGridProps> = ({ images }) => {
// 如果没有图片,不显示任何内容
if (!images || images.length === 0) return null;
// 截取前9张图片
const gridItems = images.slice(0, 9);
return (
<div className="bg-white/50 dark:bg-slate-900/50 p-4 rounded-3xl border border-slate-100 dark:border-slate-800">
<h3 className="text-sm font-bold text-slate-400 mb-3 ml-1 uppercase tracking-wider flex items-center justify-between">
<span> ()</span>
<span className="text-xs font-normal opacity-70"></span>
</h3>
<div className="grid grid-cols-3 gap-2 w-full aspect-square max-w-[400px] mx-auto">
{gridItems.map((url, index) => (
<motion.div
key={`${url}-${index}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
className="aspect-square rounded-lg overflow-hidden shadow-sm bg-slate-100 dark:bg-slate-800"
>
<img
src={url}
alt={`Grid ${index + 1}`}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-500 cursor-pointer"
onClick={() => {
// Optional: Click to preview or just let user long press
}}
/>
</motion.div>
))}
</div>
</div>
);
};

View File

@@ -1,13 +1,14 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button, Input, Toast, ErrorBlock } from 'antd-mobile';
import { Copy, RefreshCw, Sparkles, Wand2, Quote, UserCircle2, Download, Image as ImageIcon } from 'lucide-react';
import { RefreshCw, Sparkles, Wand2, UserCircle2 } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { generateCopyStream } from '../services/api';
import { Header } from '../components/Header';
import { Layout } from '../components/Layout';
import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import html2canvas from 'html2canvas';
import { CopyCard } from '../components/home/CopyCard';
import { NineGrid } from '../components/home/NineGrid';
export const Home: React.FC = () => {
const { templates, preferences, setLastUsedName, posterUrls } = useAppStore();
@@ -16,11 +17,9 @@ export const Home: React.FC = () => {
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [currentScenario, setCurrentScenario] = useState('');
const [currentPosterUrl, setCurrentPosterUrl] = useState('');
// Ref for auto-scrolling to bottom of result
const resultEndRef = useRef<HTMLDivElement>(null);
const posterRef = useRef<HTMLDivElement>(null);
const handleGenerate = async () => {
if (!name.trim()) {
@@ -33,14 +32,6 @@ export const Home: React.FC = () => {
setError('');
setResult('');
// Randomly select a poster if available
if (posterUrls.length > 0) {
const randomPoster = posterUrls[Math.floor(Math.random() * posterUrls.length)];
setCurrentPosterUrl(randomPoster);
} else {
setCurrentPosterUrl('');
}
try {
// 1. Filter enabled templates
const enabledTemplates = templates.filter(t => t.isEnabled);
@@ -73,7 +64,7 @@ export const Home: React.FC = () => {
},
(err) => {
console.error(err);
setError(err.message || '生成失败,请检查网络或联系管理员');
setError((err as Error).message || '生成失败,请检查网络或联系管理员');
setLoading(false);
},
() => {
@@ -88,59 +79,6 @@ export const Home: React.FC = () => {
}
};
const copyToClipboard = () => {
if (!result) return;
navigator.clipboard.writeText(result).then(() => {
Toast.show({ content: '已复制文案', icon: 'success' });
});
};
const copyPosterImage = async () => {
if (!posterRef.current || !result) return;
try {
const canvas = await html2canvas(posterRef.current, {
useCORS: true,
scale: 2,
backgroundColor: null
});
canvas.toBlob(async (blob) => {
if (!blob) return;
try {
// Attempt to write to clipboard
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
Toast.show({ content: '海报已复制', icon: 'success' });
} catch (err) {
console.error('Clipboard write failed:', err);
Toast.show({ content: '复制失败,请尝试保存', icon: 'fail' });
}
}, 'image/png');
} catch (err) {
console.error('Capture failed:', err);
Toast.show({ content: '生成图片失败', icon: 'fail' });
}
};
const handleSaveImage = async () => {
if (!posterRef.current || !result) return;
try {
const canvas = await html2canvas(posterRef.current, {
useCORS: true,
scale: 2,
backgroundColor: null
});
const link = document.createElement('a');
link.download = `wx-moments-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
Toast.show({ content: '海报已保存', icon: 'success' });
} catch (err) {
console.error('Save failed:', err);
Toast.show({ content: '保存失败,请重试', icon: 'fail' });
}
};
useEffect(() => {
if (resultEndRef.current) {
resultEndRef.current.scrollIntoView({ behavior: 'smooth' });
@@ -213,19 +151,14 @@ export const Home: React.FC = () => {
</motion.div>
{/* Right Section: Result */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.5, ease: "easeOut" }}
className="w-full md:w-2/3 min-h-[400px]"
>
<div className="w-full md:w-2/3 min-h-[400px]">
{error && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<ErrorBlock
status="default"
title="生成出错了"
description={error}
className="bg-white/50 dark:bg-slate-900/50 backdrop-blur rounded-2xl p-4 border border-red-200 dark:border-red-900/30"
className="bg-white/50 dark:bg-slate-900/50 backdrop-blur rounded-2xl p-4 border border-red-200 dark:border-red-900/30 mb-6"
/>
</motion.div>
)}
@@ -240,123 +173,40 @@ export const Home: React.FC = () => {
<AnimatePresence mode="wait">
{(result || loading) && (
<motion.div
key="result-card"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="relative"
key="result-container"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-6"
>
{currentScenario && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute -top-3 left-6 z-10"
>
<span className="bg-indigo-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow-lg flex items-center gap-1">
<Sparkles size={12} />
{currentScenario}
</span>
</motion.div>
)}
{/* 1. Text Card */}
<CopyCard
result={result}
loading={loading}
scenario={currentScenario}
/>
<div ref={resultEndRef} />
{/* Poster Container */}
<div
ref={posterRef}
className="relative rounded-3xl overflow-hidden shadow-2xl transition-all aspect-[3/4] md:aspect-[4/5] max-w-[500px] mx-auto bg-white dark:bg-slate-900"
>
{/* Background Image Layer */}
{currentPosterUrl && (
<div className="absolute inset-0 z-0">
<img
src={currentPosterUrl}
alt="Poster Background"
className="w-full h-full object-cover"
crossOrigin="anonymous"
/>
<div className="absolute inset-0 bg-black/40 backdrop-blur-[1px]" />
</div>
)}
{/* 2. Nine Grid Posters */}
<NineGrid images={posterUrls} />
<div className="relative z-10 h-full flex flex-col p-8 md:p-10">
<Quote className={`w-12 h-12 transform -scale-x-100 mb-6 ${currentPosterUrl ? 'text-white/60' : 'text-indigo-100 dark:text-indigo-900/30'}`} />
<div className={`flex-1 text-xl md:text-2xl leading-relaxed font-medium whitespace-pre-wrap font-sans overflow-y-auto custom-scrollbar ${currentPosterUrl ? 'text-white drop-shadow-md' : 'text-slate-700 dark:text-slate-200'}`}>
{result}
{loading && (
<span className={`inline-block w-2 h-6 ml-1 animate-pulse align-middle ${currentPosterUrl ? 'bg-white' : 'bg-indigo-500'}`} />
)}
<div ref={resultEndRef} />
</div>
<div className={`mt-8 pt-6 border-t ${currentPosterUrl ? 'border-white/20' : 'border-slate-100 dark:border-slate-800'}`}>
<div className="flex items-center justify-between">
<div className={`text-sm ${currentPosterUrl ? 'text-white/80' : 'text-slate-400'}`}>
Generated by AI
</div>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${currentPosterUrl ? 'bg-white/20' : 'bg-indigo-50'}`}>
<Sparkles size={14} className={currentPosterUrl ? 'text-white' : 'text-indigo-500'} />
</div>
</div>
</div>
</div>
</div>
{/* Actions Bar (Outside of Poster Ref) */}
<div className="mt-6 flex flex-wrap justify-center md:justify-end gap-3">
<Button
size="middle"
fill="none"
onClick={handleGenerate}
disabled={loading}
className="bg-white/50 dark:bg-slate-800/50 backdrop-blur text-slate-600 dark:text-slate-300 hover:bg-white/80 dark:hover:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700"
>
<RefreshCw size={18} className={`mr-2 ${loading ? 'animate-spin' : ''}`} />
{loading ? '重写' : '换一个'}
</Button>
<Button
size="middle"
color="primary"
fill="solid"
onClick={copyToClipboard}
disabled={loading || !result}
className="rounded-xl px-6 bg-indigo-600 hover:bg-indigo-700 border-none shadow-md shadow-indigo-500/20 flex items-center active:scale-95 transition-transform"
>
<Copy size={18} className="mr-2" />
</Button>
{currentPosterUrl && result && !loading && (
<>
<Button
size="middle"
color="warning"
fill="solid"
onClick={copyPosterImage}
className="rounded-xl px-6 bg-amber-500 hover:bg-amber-600 border-none shadow-md shadow-amber-500/20 flex items-center text-white active:scale-95 transition-transform"
>
<ImageIcon size={18} className="mr-2" />
</Button>
<Button
size="middle"
color="success"
fill="solid"
onClick={handleSaveImage}
className="rounded-xl px-6 bg-emerald-500 hover:bg-emerald-600 border-none shadow-md shadow-emerald-500/20 flex items-center text-white active:scale-95 transition-transform"
>
<Download size={18} className="mr-2" />
</Button>
</>
)}
{/* 3. Global Actions */}
<div className="flex justify-center md:justify-end gap-3 pb-8">
<Button
size="middle"
fill="none"
onClick={handleGenerate}
disabled={loading}
className="bg-white/50 dark:bg-slate-800/50 backdrop-blur text-slate-600 dark:text-slate-300 hover:bg-white/80 dark:hover:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700"
>
<RefreshCw size={18} className={`mr-2 ${loading ? 'animate-spin' : ''}`} />
{loading ? '生成中...' : '换个方案'}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</main>
<footer className="py-6 text-center text-xs text-slate-400 dark:text-slate-600">