This commit is contained in:
eric
2026-06-06 04:29:46 -05:00
parent 88aa96a2a1
commit 41493c35ca
50 changed files with 4666 additions and 416 deletions

View File

@@ -1,22 +1,134 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
const MOCK_GIGS = [
{ id: "1", title: "帮我 P 图", city: "大理", reward: "¥50", author: "林**" },
{ id: "2", title: "找惠州 loft 民宿", city: "深圳", reward: "¥100", author: "陈**" },
{ id: "3", title: "代收快递", city: "成都", reward: "¥30", author: "王**" },
{ id: "4", title: "一起 citywalk 拍 neo2 航拍", city: "杭州", reward: "面议", author: "张**" },
{ id: "5", title: "翻译英文文档", city: "上海", reward: "¥200", author: "李**" },
];
interface Gig {
id: string;
title: string;
category: string;
budget: number;
location: string;
description: string;
status: "open" | "in_progress" | "done" | string;
}
const INITIAL_FORM = {
title: "",
category: "内容",
budget: 500,
location: "远程",
description: "",
};
const STATUS_LABELS: Record<string, string> = {
open: "可接单",
in_progress: "进行中",
done: "已完成",
};
export default function GigsPage() {
const { t } = useTranslation("gigs");
const [gigs, setGigs] = useState<Gig[]>([]);
const [form, setForm] = useState(INITIAL_FORM);
const [showForm, setShowForm] = useState(false);
const [status, setStatus] = useState("");
const [submitting, setSubmitting] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null);
const openGigs = useMemo(() => gigs.filter((gig) => gig.status === "open"), [gigs]);
const activeGigs = useMemo(() => gigs.filter((gig) => gig.status !== "open"), [gigs]);
const loadGigs = () => {
apiFetch<{ items: Gig[] }>("/api/gigs")
.then((data) => setGigs(data.items || []))
.catch(() => setGigs([]));
};
useEffect(() => {
loadGigs();
}, []);
const submitGig = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.title.trim()) {
setStatus("请先填写任务标题");
return;
}
setSubmitting(true);
setStatus("");
try {
const data = await apiFetch<{ record: Gig }>("/api/gigs", {
method: "POST",
body: JSON.stringify(form),
});
setGigs((prev) => [data.record, ...prev]);
setForm(INITIAL_FORM);
setShowForm(false);
setStatus("任务已发布,已写入 PocketBase。");
} catch (error) {
setStatus(error instanceof Error ? error.message : "发布失败");
} finally {
setSubmitting(false);
}
};
const applyGig = async (gig: Gig) => {
setApplyingId(gig.id);
setStatus("");
try {
const data = await apiFetch<{ record: Gig; message: string }>(`/api/gigs/${gig.id}/apply`, {
method: "POST",
});
setGigs((prev) => prev.map((item) => (item.id === gig.id ? data.record : item)));
setStatus(`${gig.title}${data.message}`);
} catch (error) {
setStatus(error instanceof Error ? error.message : "接单失败");
} finally {
setApplyingId(null);
}
};
const renderGig = (gig: Gig) => (
<article
key={gig.id}
className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-5"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
{gig.category}
</span>
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">
{STATUS_LABELS[gig.status] || gig.status}
</span>
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{gig.title}</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
📍 {gig.location} · ¥{Number(gig.budget || 0).toLocaleString()}
</p>
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-2">
{gig.description || "需求方暂未填写详细说明,接单后可继续沟通。"}
</p>
</div>
<button
type="button"
onClick={() => applyGig(gig)}
disabled={gig.status !== "open" || applyingId === gig.id}
className="shrink-0 rounded-xl bg-[#ff4d4f] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#ff3333] disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 dark:disabled:bg-gray-700"
>
{gig.status === "open" ? (applyingId === gig.id ? "提交中" : "接单") : "已锁定"}
</button>
</div>
</article>
);
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-12">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
@@ -26,36 +138,113 @@ export default function GigsPage() {
{t("subtitle")} · {t("platformFee")}
</p>
</div>
<button className="shrink-0 px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff3333] transition-colors">
{t("publish")}
<button
type="button"
onClick={() => setShowForm((prev) => !prev)}
className="shrink-0 px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff3333] transition-colors"
>
{showForm ? "收起表单" : t("publish")}
</button>
</div>
<div className="space-y-4">
{MOCK_GIGS.map((gig) => (
<div
key={gig.id}
className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{gig.title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
📍 {gig.city} · {gig.author}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-lg font-semibold text-[#ff4d4f]">
{gig.reward}
</span>
<button className="px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700">
{t("browse")}
</button>
</div>
{showForm && (
<form
onSubmit={submitGig}
className="mb-8 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6"
>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2 block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<input
value={form.title}
onChange={(event) => setForm({ ...form, title: event.target.value })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
placeholder="例如:补充大理共享办公空间清单"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<select
value={form.category}
onChange={(event) => setForm({ ...form, category: event.target.value })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
>
{["内容", "调研", "摄影", "活动", "设计", "开发"].map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<input
type="number"
min={1}
value={form.budget}
onChange={(event) => setForm({ ...form, budget: Number(event.target.value) || 1 })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<input
value={form.location}
onChange={(event) => setForm({ ...form, location: event.target.value })}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
/>
</label>
<label className="sm:col-span-2 block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
<textarea
value={form.description}
onChange={(event) => setForm({ ...form, description: event.target.value })}
rows={4}
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
placeholder="交付物、时间、资料来源、验收标准"
/>
</label>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-xl bg-gray-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400 dark:bg-gray-100 dark:text-gray-900"
>
{submitting ? "发布中" : "写入数据库并发布"}
</button>
</div>
</form>
)}
{status && (
<div className="mb-5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-200">
{status}
</div>
)}
<section className="mb-10">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
<span className="text-sm text-gray-400 dark:text-gray-500">{openGigs.length} </span>
</div>
<div className="space-y-4">
{openGigs.map(renderGig)}
{openGigs.length === 0 && (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
</div>
)}
</div>
</section>
{activeGigs.length > 0 && (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
<span className="text-sm text-gray-400 dark:text-gray-500">{activeGigs.length} </span>
</div>
<div className="space-y-4">{activeGigs.map(renderGig)}</div>
</section>
)}
</div>
<Footer />
</div>