Files
gitlab-instance-0a899031_cn…/app/[locale]/gigs/page.tsx
2026-06-06 04:29:46 -05:00

253 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
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-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">
{t("title")}
</h1>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t("subtitle")} · {t("platformFee")}
</p>
</div>
<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>
{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 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>
);
}