项目文件夹

文件
2026-08-20 11:19:23 +00:00

1062 行
29 KiB
HTML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="武汉 OPC 社区活动征集与 AI 共创助手">
<title>武汉 OPC|和 AI 共创一场活动</title>
<style>
:root {
color-scheme: light;
--river: #0d5c73;
--river-deep: #12323d;
--lake: #dff3f1;
--paper: #f7fbfa;
--lotus: #d94c71;
--sun: #f4b860;
--ink: #152a32;
--muted: #62767d;
--line: #bad9d7;
--white: #fff;
font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-width: 320px;
margin: 0;
background:
linear-gradient(90deg, rgb(13 92 115 / 4%) 1px, transparent 1px) 0 0 / 48px 48px,
linear-gradient(rgb(13 92 115 / 4%) 1px, transparent 1px) 0 0 / 48px 48px,
var(--paper);
color: var(--ink);
}
button,
input,
select,
textarea {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
a {
color: inherit;
}
.page {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
}
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 76px;
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 50%;
background: var(--river);
color: white;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: -0.06em;
}
.brand strong {
display: block;
font-size: 1rem;
}
.brand span {
color: var(--muted);
font-size: 0.75rem;
}
nav {
display: flex;
gap: 26px;
}
nav a {
color: var(--muted);
font-size: 0.88rem;
font-weight: 700;
text-decoration: none;
}
nav a:hover {
color: var(--river);
}
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(240px, 0.7fr);
gap: 56px;
padding: 82px 0 70px;
overflow: hidden;
}
.hero::after {
position: absolute;
right: 2%;
bottom: 18%;
width: 360px;
height: 86px;
border-top: 12px solid rgb(13 92 115 / 12%);
border-bottom: 12px solid rgb(13 92 115 / 12%);
border-radius: 50%;
content: "";
transform: rotate(-9deg);
pointer-events: none;
}
.eyebrow {
display: inline-flex;
gap: 10px;
align-items: center;
margin: 0 0 20px;
color: var(--river);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.eyebrow::before {
width: 34px;
height: 3px;
background: var(--lotus);
content: "";
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
max-width: 790px;
margin-bottom: 28px;
font-family: "STKaiti", "KaiTi", "FangSong", serif;
font-size: clamp(3.4rem, 8.5vw, 7.8rem);
font-weight: 700;
line-height: 0.92;
letter-spacing: -0.09em;
}
h1 em {
color: var(--lotus);
font-style: normal;
}
.hero-copy {
max-width: 670px;
color: var(--muted);
font-size: clamp(1rem, 2vw, 1.18rem);
line-height: 1.8;
}
.hero-ticket {
position: relative;
z-index: 1;
align-self: end;
border: 1px solid var(--river);
border-radius: 2px;
padding: 26px;
background: var(--lake);
box-shadow: 14px 14px 0 var(--sun);
transform: rotate(2deg);
}
.ticket-label {
margin-bottom: 36px;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero-ticket strong {
display: block;
margin-bottom: 12px;
font-family: "STKaiti", "KaiTi", serif;
font-size: 2.2rem;
}
.hero-ticket p {
margin-bottom: 0;
color: var(--river);
line-height: 1.7;
}
.call-strip {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 72px;
border-block: 1px solid var(--line);
padding: 16px 0;
overflow-x: auto;
color: var(--river);
white-space: nowrap;
}
.call-strip strong {
color: var(--lotus);
}
.call-strip span::after {
margin-left: 14px;
color: var(--line);
content: "◆";
}
.section-heading {
display: flex;
gap: 24px;
align-items: end;
justify-content: space-between;
margin-bottom: 28px;
}
.section-heading h2 {
margin-bottom: 0;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(2.4rem, 5vw, 4.7rem);
line-height: 1;
letter-spacing: -0.06em;
}
.section-heading p {
max-width: 420px;
margin-bottom: 4px;
color: var(--muted);
line-height: 1.65;
}
.collection-grid {
display: grid;
grid-template-columns: minmax(320px, 0.82fr) minmax(0, 1.18fr);
gap: 28px;
align-items: start;
margin-bottom: 92px;
}
.proposal-form {
border: 1px solid var(--line);
border-radius: 24px;
padding: 28px;
background: rgb(255 255 255 / 86%);
box-shadow: 0 24px 70px rgb(21 42 50 / 9%);
}
.cocreate {
position: relative;
margin: -10px -10px 26px;
border-radius: 18px;
padding: 24px;
overflow: hidden;
background: var(--river-deep);
color: white;
}
.cocreate::after {
position: absolute;
top: -68px;
right: -54px;
width: 180px;
height: 180px;
border: 38px solid rgb(223 243 241 / 7%);
border-radius: 50%;
content: "";
pointer-events: none;
}
.flow-label {
position: relative;
z-index: 1;
margin-bottom: 8px;
color: var(--sun);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.cocreate h3 {
position: relative;
z-index: 1;
margin-bottom: 8px;
font-family: "STKaiti", "KaiTi", serif;
font-size: 2rem;
}
.cocreate-intro {
position: relative;
z-index: 1;
margin-bottom: 18px;
color: rgb(255 255 255 / 66%);
font-size: 0.88rem;
line-height: 1.65;
}
.idea-label {
position: relative;
z-index: 1;
color: white;
}
.cocreate textarea {
min-height: 104px;
border-color: rgb(255 255 255 / 18%);
background: rgb(255 255 255 / 8%);
color: white;
}
.cocreate textarea::placeholder {
color: rgb(255 255 255 / 42%);
}
.idea-examples {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: -4px 0 16px;
}
.idea-chip {
border: 1px solid rgb(255 255 255 / 22%);
border-radius: 999px;
padding: 6px 10px;
background: transparent;
color: rgb(255 255 255 / 78%);
cursor: pointer;
font-size: 0.74rem;
}
.idea-chip:hover {
border-color: var(--sun);
color: var(--sun);
}
.cocreate-actions {
position: relative;
z-index: 1;
display: flex;
gap: 12px;
align-items: center;
}
.draft-button {
border: 0;
border-radius: 10px;
padding: 11px 18px;
background: var(--sun);
color: var(--river-deep);
cursor: pointer;
font-weight: 900;
}
.draft-status {
color: rgb(255 255 255 / 60%);
font-size: 0.8rem;
}
.draft-preview {
position: relative;
z-index: 1;
margin-top: 20px;
border: 1px dashed rgb(255 255 255 / 28%);
border-radius: 12px;
padding: 18px;
background: rgb(255 255 255 / 8%);
}
.draft-preview[hidden] {
display: none;
}
.draft-kicker {
margin-bottom: 9px;
color: var(--sun);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.draft-preview h4 {
margin: 0 0 10px;
font-size: 1.16rem;
}
.draft-meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 12px;
}
.draft-meta span {
border-radius: 999px;
padding: 4px 8px;
background: rgb(223 243 241 / 14%);
color: var(--lake);
font-size: 0.72rem;
font-weight: 800;
}
.draft-copy {
margin-bottom: 14px;
color: rgb(255 255 255 / 74%);
font-size: 0.84rem;
line-height: 1.65;
white-space: pre-wrap;
}
.adopt-button {
border: 1px solid var(--sun);
border-radius: 9px;
padding: 9px 13px;
background: transparent;
color: var(--sun);
cursor: pointer;
font-weight: 800;
}
.manual-step {
display: flex;
gap: 12px;
align-items: center;
margin: 0 0 18px;
color: var(--river);
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0.04em;
}
.manual-step::after {
height: 1px;
flex: 1;
background: var(--line);
content: "";
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 14px;
}
label {
display: grid;
gap: 8px;
margin-bottom: 16px;
color: var(--river-deep);
font-size: 0.84rem;
font-weight: 800;
}
label.full {
grid-column: 1 / -1;
}
input,
select,
textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 13px;
outline: none;
background: white;
color: var(--ink);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
textarea {
min-height: 130px;
resize: vertical;
line-height: 1.6;
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible {
border-color: var(--lotus);
box-shadow: 0 0 0 4px rgb(217 76 113 / 14%);
}
.primary-button {
width: 100%;
border: 0;
border-radius: 12px;
padding: 14px 20px;
background: var(--lotus);
color: white;
cursor: pointer;
font-weight: 800;
}
.primary-button:hover {
background: #bd365b;
}
button:disabled {
cursor: wait;
opacity: 0.62;
}
.form-status {
min-height: 22px;
margin: 12px 0 0;
color: var(--river);
font-size: 0.88rem;
}
.event-board {
display: grid;
gap: 14px;
}
.event-card {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--line);
border-radius: 18px;
background: white;
}
.event-date {
display: grid;
align-content: center;
padding: 18px 12px;
background: var(--river);
color: white;
text-align: center;
}
.event-date strong {
font-family: "STKaiti", "KaiTi", serif;
font-size: 2rem;
line-height: 1;
}
.event-date span {
margin-top: 6px;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.06em;
}
.event-content {
padding: 18px 20px;
}
.event-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.tag {
border-radius: 999px;
padding: 4px 9px;
background: var(--lake);
color: var(--river);
font-size: 0.72rem;
font-weight: 800;
}
.tag.status {
background: #fff0d8;
color: #855300;
}
.event-content h3 {
margin-bottom: 8px;
font-size: 1.12rem;
}
.event-content p {
display: -webkit-box;
margin-bottom: 10px;
overflow: hidden;
color: var(--muted);
line-height: 1.6;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.event-place {
color: var(--river);
font-size: 0.82rem;
font-weight: 700;
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 18px;
padding: 54px 24px;
color: var(--muted);
text-align: center;
}
footer {
display: flex;
justify-content: space-between;
border-top: 1px solid var(--line);
padding: 26px 0 40px;
color: var(--muted);
font-size: 0.82rem;
}
@media (max-width: 860px) {
.hero,
.collection-grid {
grid-template-columns: 1fr;
}
.hero {
gap: 30px;
padding-top: 56px;
}
.hero-ticket {
max-width: 420px;
}
.section-heading {
display: block;
}
.section-heading p {
margin-top: 14px;
}
}
@media (max-width: 620px) {
.page {
width: min(100% - 22px, 1180px);
}
nav {
display: none;
}
h1 {
font-size: clamp(3.2rem, 19vw, 5.3rem);
}
.form-grid {
grid-template-columns: 1fr;
}
label.full {
grid-column: auto;
}
.proposal-form {
padding: 20px;
}
.event-card {
grid-template-columns: 74px minmax(0, 1fr);
}
.cocreate-actions {
display: grid;
}
.draft-button {
width: 100%;
}
footer {
display: grid;
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
* {
transition: none !important;
}
}
</style>
</head>
<body>
<header class="page masthead">
<a class="brand" href="#">
<span class="brand-mark">OPC<br>WH</span>
<span>
<strong>武汉 OPC 社区</strong>
<span>一人公司 · 独立创造者</span>
</span>
</a>
<nav aria-label="页面导航">
<a href="#collection">AI 共创提案</a>
<a href="#events">最新提案</a>
</nav>
</header>
<main class="page">
<section class="hero">
<div>
<p class="eyebrow">Wuhan open call · 2026</p>
<h1>一句想法,长成<em>江城</em>活动。</h1>
<p class="hero-copy">
无论你是独立开发者、产品人、设计师,还是正在经营一人公司的行动者,
先告诉 AI 你想聚集怎样的人、一起做什么。它会整理成活动草案,再由你确认并发起。
</p>
</div>
<aside class="hero-ticket" aria-label="征集说明">
<div class="ticket-label">30.5928° N / 114.3055° E</div>
<strong>活动接力票</strong>
<p>想法由你点亮,草案由 AI 整理,最终安排由你决定。社区会接住每一份认真发起。</p>
</aside>
</section>
<div class="call-strip" aria-label="征集类型">
<strong>正在征集</strong>
<span>技术共学</span>
<span>产品交流</span>
<span>独立开发</span>
<span>城市漫游</span>
<span>创意市集</span>
<span>更多自发活动</span>
</div>
<section id="collection">
<div class="section-heading">
<h2>和 AI 共创活动提案</h2>
<p>从一句不完整的念头开始,生成草案、人工确认,再补充真实日期和联系方式。</p>
</div>
<div class="collection-grid">
<form class="proposal-form" id="event-form">
<section class="cocreate" aria-labelledby="cocreate-title">
<div class="flow-label">Step 1 · 先说一个想法</div>
<h3 id="cocreate-title">江城活动共创台</h3>
<p class="cocreate-intro">
不用先写完整方案。说清楚想和谁、做什么,已选择的类型、区域和地点也会一起交给 AI 参考。
</p>
<label class="idea-label" for="event-idea">
你的活动念头
<textarea id="event-idea" minlength="8" maxlength="800" placeholder="例如:想找 20 位独立开发者,在周末互相体验还没上线的产品,并给出真诚反馈"></textarea>
</label>
<div class="idea-examples" aria-label="活动想法示例">
<button class="idea-chip" type="button">做一场独立产品互诊</button>
<button class="idea-chip" type="button">沿东湖边走边聊一人公司</button>
<button class="idea-chip" type="button">让设计师和开发者交换工作流</button>
</div>
<div class="cocreate-actions">
<button class="draft-button" id="draft-submit" type="button">生成活动草案</button>
<span class="draft-status" id="draft-status" aria-live="polite">AI 只负责整理,提交前由你确认。</span>
</div>
<section class="draft-preview" id="draft-preview" aria-live="polite" hidden>
<div class="draft-kicker">AI draft · 待确认</div>
<h4 id="draft-title"></h4>
<div class="draft-meta">
<span id="draft-category"></span>
<span id="draft-district"></span>
<span id="draft-venue"></span>
</div>
<p class="draft-copy" id="draft-description"></p>
<button class="adopt-button" id="adopt-draft" type="button">采用这份草案并继续</button>
</section>
</section>
<div class="manual-step">Step 2 · 确认草案并补充真实信息</div>
<div class="form-grid">
<label class="full">
活动名称
<input name="title" maxlength="100" required placeholder="例如:独立开发者周末产品诊所">
</label>
<label>
发起人 / 团队
<input name="organizer" maxlength="60" required placeholder="你的称呼">
</label>
<label>
活动类型
<select name="category" required>
<option value="">请选择</option>
<option>技术共学</option>
<option>产品交流</option>
<option>独立开发</option>
<option>城市漫游</option>
<option>创意市集</option>
<option>其他</option>
</select>
</label>
<label>
活动区域
<select name="district" required>
<option value="">请选择</option>
<option>江岸区</option>
<option>江汉区</option>
<option>硚口区</option>
<option>汉阳区</option>
<option>武昌区</option>
<option>青山区</option>
<option>洪山区</option>
<option>东西湖区</option>
<option>线上</option>
</select>
</label>
<label>
计划日期
<input name="event_date" type="date" required>
</label>
<label class="full">
场地 / 集合点
<input name="venue" maxlength="120" required placeholder="可以先写意向区域或线上">
</label>
<label class="full">
活动说明
<textarea name="description" minlength="20" maxlength="1200" required placeholder="介绍活动主题、适合谁参加、你希望现场发生什么(至少 20 字)"></textarea>
</label>
<label class="full">
联系方式(仅社区运营可见)
<input name="contact" maxlength="160" required placeholder="微信、邮箱或其他联系方式">
</label>
</div>
<button class="primary-button" id="event-submit" type="submit">提交活动提案</button>
<p class="form-status" id="event-status" aria-live="polite"></p>
</form>
<div id="events">
<div class="event-board" id="event-board">
<div class="empty-state">正在读取社区提案...</div>
</div>
</div>
</div>
</section>
</main>
<footer class="page">
<span>武汉 OPC 社区 AI 活动共创 Demo</span>
<span>想法由人发起,AI 帮它变得可行动</span>
</footer>
<script>
const apiBase = (() => {
const envID = location.pathname.split("/").filter(Boolean)[0];
return envID ? `/${envID}/api/cloud` : "api/cloud";
})();
const eventForm = document.querySelector("#event-form");
const eventSubmit = document.querySelector("#event-submit");
const eventStatus = document.querySelector("#event-status");
const eventBoard = document.querySelector("#event-board");
const ideaInput = document.querySelector("#event-idea");
const draftSubmit = document.querySelector("#draft-submit");
const draftStatus = document.querySelector("#draft-status");
const draftPreview = document.querySelector("#draft-preview");
const adoptDraft = document.querySelector("#adopt-draft");
let currentDraft = null;
function escapeHTML(value) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
}[character]));
}
async function requestJSON(path, init) {
const response = await fetch(`${apiBase}${path}`, init);
let payload;
try {
payload = await response.json();
} catch (error) {
throw new Error(`服务返回了无法读取的内容(${response.status}`);
}
if (!response.ok || payload.ok === false) {
throw new Error(payload.error || `请求失败(${response.status}`);
}
return payload;
}
function renderDate(value) {
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) {
return {day: "--", month: "待定"};
}
return {
day: String(date.getDate()).padStart(2, "0"),
month: `${date.getMonth() + 1}月`
};
}
function renderEvent(event) {
const date = renderDate(event.event_date);
return `
<article class="event-card">
<div class="event-date">
<strong>${escapeHTML(date.day)}</strong>
<span>${escapeHTML(date.month)}</span>
</div>
<div class="event-content">
<div class="event-meta">
<span class="tag">${escapeHTML(event.category)}</span>
<span class="tag">${escapeHTML(event.district)}</span>
<span class="tag status">${escapeHTML(event.status)}</span>
</div>
<h3>${escapeHTML(event.title)}</h3>
<p>${escapeHTML(event.description)}</p>
<div class="event-place">📍 ${escapeHTML(event.venue)} · 发起人 ${escapeHTML(event.organizer)}</div>
</div>
</article>
`;
}
async function loadEvents() {
try {
const payload = await requestJSON("/events");
const events = Array.isArray(payload.events) ? payload.events : [];
eventBoard.innerHTML = events.length
? events.map(renderEvent).join("")
: '<div class="empty-state">还没有活动提案。提交第一场活动,让接力从你开始。</div>';
} catch (error) {
eventBoard.innerHTML = `<div class="empty-state">${escapeHTML(error.message)}</div>`;
}
}
eventForm.addEventListener("submit", async (event) => {
event.preventDefault();
eventSubmit.disabled = true;
eventStatus.textContent = "正在提交...";
const data = Object.fromEntries(new FormData(eventForm).entries());
try {
await requestJSON("/events", {
method: "POST",
headers: {"content-type": "application/json"},
body: JSON.stringify(data)
});
eventForm.reset();
currentDraft = null;
draftPreview.hidden = true;
draftStatus.textContent = "AI 只负责整理,提交前由你确认。";
document.querySelector('[name="event_date"]').min = new Date().toISOString().slice(0, 10);
eventStatus.textContent = "提案已收到,期待在武汉见面。";
await loadEvents();
} catch (error) {
eventStatus.textContent = error.message;
} finally {
eventSubmit.disabled = false;
}
});
document.querySelectorAll(".idea-chip").forEach((button) => {
button.addEventListener("click", () => {
ideaInput.value = button.textContent.trim();
ideaInput.focus();
});
});
draftSubmit.addEventListener("click", async () => {
const idea = ideaInput.value.trim();
if (idea.length < 8) {
draftStatus.textContent = "请用至少 8 个字说说想办什么";
ideaInput.focus();
return;
}
const formData = Object.fromEntries(new FormData(eventForm).entries());
const context = {
idea,
category: formData.category,
district: formData.district,
venue: formData.venue,
event_date: formData.event_date
};
draftSubmit.disabled = true;
draftStatus.textContent = "正在把想法整理成活动草案...";
try {
const payload = await requestJSON("/event-draft", {
method: "POST",
headers: {"content-type": "application/json"},
body: JSON.stringify(context)
});
currentDraft = payload.draft;
document.querySelector("#draft-title").textContent = currentDraft.title;
document.querySelector("#draft-category").textContent = currentDraft.category;
document.querySelector("#draft-district").textContent = currentDraft.district;
document.querySelector("#draft-venue").textContent = `场地:${currentDraft.venue}`;
document.querySelector("#draft-description").textContent = currentDraft.description;
draftPreview.hidden = false;
draftStatus.textContent = "草案已生成,请确认后采用";
} catch (error) {
draftStatus.textContent = error.message;
} finally {
draftSubmit.disabled = false;
}
});
adoptDraft.addEventListener("click", () => {
if (!currentDraft) {
return;
}
Object.entries(currentDraft).forEach(([name, value]) => {
const control = eventForm.elements.namedItem(name);
if (control) {
control.value = value;
}
});
draftStatus.textContent = "草案已回填,请补充日期、发起人和联系方式";
eventForm.elements.namedItem("organizer").focus();
document.querySelector(".manual-step").scrollIntoView({behavior: "smooth", block: "start"});
});
document.querySelector('[name="event_date"]').min = new Date().toISOString().slice(0, 10);
loadEvents();
</script>
</body>
</html>