253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|