基于婚纱摄影网站源码搭建作品展示与预约功能

基于婚纱摄影网站源码搭建作品展示与预约功能

基于婚纱摄影网站源码搭建作品展示与预约功能

婚纱摄影网站通常已有首页、套餐介绍、案例展示和联系方式,但“能看”不等于“能预约”。要让访客从浏览作品走到提交预约,至少需要解决两件事:作品内容如何组织与筛选;预约信息如何验证、保存,并避免同一时段被重复占用。

本文以一个常见的 Node.js + Express 项目为例,搭建一个可运行的最小版本:前端展示摄影作品并按风格筛选;访客填写姓名、联系方式、拍摄日期和时段后,后端验证并写入 SQLite。这个版本适合单门店、预约规则简单、正在验证流程的项目。它不是完整的客户管理系统,也不包含支付、短信通知或员工排班。

一、适用场景与边界

  • 适合:现有网站源码使用常见静态页面或 Express 服务,希望先上线作品筛选与基础预约表单。
  • 适合:预约规则简单,例如每天只有若干固定时段,且一间门店共享这些时段。
  • 不适合直接照搬:多门店、多摄影师、化妆师与场地资源需要同时排班的系统。此类项目必须定义资源冲突规则,不能只用“日期 + 时段”判重。
  • 不适合替代:正式的客户关系管理、订单支付、合同签署和生产级排班系统。示例只负责收集预约申请,并不代表预约已经确认。

开始前先检查源码的技术栈、路由方式和部署环境。若现有项目使用 PHP、Java 或其他框架,应把本文的校验与数据约束思路迁移到对应技术栈,而不是将 Express 服务硬塞进原项目。以下示例要求已安装 Node.js,使用 Node.js 20 或较新的 LTS 版本;依赖版本由 npm 安装时的项目配置确定。

二、确定作品与预约的数据边界

作品展示和预约数据是两类不同内容。作品是公开内容,可包含标题、风格标签、封面地址和简介;预约记录包含姓名、联系方式、日期和时段,属于个人信息,不应出现在公开作品接口或页面源代码中。

示例将作品数据放在前端常量中,便于先跑通功能。正式运营时,建议把作品迁移到内容管理系统或数据库,并通过管理端维护。封面图应使用经过压缩的 WebP、AVIF 或 JPEG 等合适格式,控制文件体积;不要把原始相机大图直接作为列表缩略图。

预约数据使用 SQLite 保存。它适用于单机、小流量和原型项目。若网站运行在多台服务器、使用无持久化的临时文件系统,或并发写入量较大,应使用具有持久化和备份方案的数据库服务。部署前还要确认 SQLite 文件所在目录可写,并纳入备份策略。

三、创建可运行项目

新建目录并初始化项目,安装 Express 和 SQLite 依赖:

mkdir wedding-studio
cd wedding-studio
npm init -y
npm install express better-sqlite3

创建以下结构:

wedding-studio/
├── server.js
└── public/
    ├── index.html
    └── app.js

下面的服务端示例包含四项关键工作:创建预约表、对请求字段做服务端校验、通过数据库唯一约束处理重复时段、只返回预约结果而不公开个人信息。时段、套餐和日期范围都是示例业务规则,项目上线前应按真实营业安排调整。

四、编写预约接口与数据库约束

const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');

const app = express();
const db = new Database(path.join(__dirname, 'bookings.sqlite'));

// 接口只接受 JSON 请求体;limit 用于限制解析的请求体大小。
app.use(express.json({ limit: '10kb' }));
app.use(express.static(path.join(__dirname, 'public')));

db.exec(`
  CREATE TABLE IF NOT EXISTS bookings (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    phone TEXT NOT NULL,
    email TEXT,
    package_id TEXT NOT NULL,
    booking_date TEXT NOT NULL,
    slot TEXT NOT NULL,
    note TEXT NOT NULL DEFAULT '',
    status TEXT NOT NULL DEFAULT 'pending',
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  );

  CREATE UNIQUE INDEX IF NOT EXISTS unique_active_booking_slot
  ON bookings (booking_date, slot)
  WHERE status != 'cancelled';
`);

const packages = new Set(['classic', 'garden', 'destination']);
const slots = new Set(['09:00', '13:00', '16:00']);

function isValidDate(value) {
  if (typeof value !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {
    return false;
  }

  const date = new Date(`${value}T00:00:00.000Z`);
  return !Number.isNaN(date.getTime()) &&
    date.toISOString().slice(0, 10) === value;
}

function isWithinBookingWindow(value) {
  if (!isValidDate(value)) return false;

  const today = new Date();
  today.setUTCHours(0, 0, 0, 0);

  const selected = new Date(`${value}T00:00:00.000Z`);
  const maxDate = new Date(today);
  maxDate.setUTCDate(maxDate.getUTCDate() + 180);

  return selected >= today && selected <= maxDate;
}

function text(value, maxLength) {
  return typeof value === 'string'
    ? value.trim().slice(0, maxLength)
    : '';
}

app.post('/api/bookings', (req, res) => {
  const name = text(req.body?.name, 80);
  const phone = text(req.body?.phone, 40);
  const email = text(req.body?.email, 254);
  const packageId = text(req.body?.packageId, 40);
  const bookingDate = text(req.body?.date, 10);
  const slot = text(req.body?.slot, 5);
  const note = text(req.body?.note, 500);

  if (name.length < 2) {
    return res.status(400).json({ error: '请填写至少 2 个字符的姓名。' });
  }

  // 仅限制字符类别和长度,不验证号码是否真实或能否接收短信。
  if (!/^[0-9+()\\-\\s]{6,40}$/.test(phone)) {
    return res.status(400).json({ error: '请检查联系电话格式。' });
  }

  if (email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {
    return res.status(400).json({ error: '请检查邮箱格式。' });
  }

  if (!packages.has(packageId)) {
    return res.status(400).json({ error: '请选择有效套餐。' });
  }

  if (!isWithinBookingWindow(bookingDate)) {
    return res.status(400).json({ error: '日期须在今天至未来 180 天内。' });
  }

  if (!slots.has(slot)) {
    return res.status(400).json({ error: '请选择有效拍摄时段。' });
  }

  try {
    const result = db.prepare(`
      INSERT INTO bookings
        (name, phone, email, package_id, booking_date, slot, note)
      VALUES
        (@name, @phone, @email, @packageId, @bookingDate, @slot, @note)
    `).run({
      name,
      phone,
      email: email || null,
      packageId,
      bookingDate,
      slot,
      note
    });

    return res.status(201).json({
      id: result.lastInsertRowid,
      message: '预约申请已提交,门店确认后会与您联系。'
    });
  } catch (error) {
    if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
      return res.status(409).json({ error: '该时段已被预约,请选择其他时段。' });
    }

    console.error('创建预约失败');
    return res.status(500).json({ error: '暂时无法提交,请稍后重试。' });
  }
});

const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
  console.log(`Server listening on http://localhost:${port}`);
});

日期校验分成两步:先检查 YYYY-MM-DD 格式,并确认日期真实存在;再限制在今天至未来 180 天内。这里使用 UTC 日期做比较,避免直接解析不带时区的日期字符串时产生格式差异。若门店营业规则依赖当地时区,应该明确门店时区,并在服务端按该时区计算“今天”,不要默认服务器时区就是门店时区。

姓名、电话和备注长度限制是此接口的业务上限,不是浏览器或 Express 对所有应用统一规定的限制。电话正则只做输入格式筛选,不能证明号码真实、属于提交人或可以接收消息。需要验证可达性时,应设计单独的短信验证流程,并考虑验证码频率、有效期和滥用防护。

唯一索引是防止重复占用的关键。仅在 JavaScript 中先查一次“这个时段是否空闲”,再插入记录,会遇到并发竞争:两个请求可能同时查到空闲,随后都写入。数据库唯一约束能让最终只有一个请求成功。示例规定同一日期同一时段只能有一条非取消预约;取消状态的记录不再占用时段。若业务还要区分门店、摄影师或套餐资源,唯一索引必须相应调整。

该接口创建的是 pending(待确认)申请,不应在页面上误称为“预约成功”或“已锁定档期”。示例没有提供取消、确认、改期接口,也没有公开查询预约详情的接口。实际项目应由工作人员核实档期后,通过受控后台确认,并制定取消与改期规则。

五、制作作品列表与预约表单

在 public/index.html 中添加页面结构。作品图片使用真实项目中的文件路径替换示例路径;以下代码不包含图片文件本身。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>婚纱摄影作品与预约</title>
</head>
<body>
  <main>
    <section aria-labelledby="works-title">
      <h1 id="works-title">婚纱摄影作品</h1>
      <label for="style-filter">按风格筛选</label>
      <select id="style-filter">
        <option value="all">全部风格</option>
        <option value="garden">森系</option>
        <option value="city">城市</option>
        <option value="classic">经典</option>
      </select>
      <div id="works" class="works" aria-live="polite"></div>
    </section>

    <section aria-labelledby="booking-title">
      <h2 id="booking-title">提交预约申请</h2>
      <form id="booking-form">
        <label>
          姓名
          <input name="name" required minlength="2" maxlength="80" autocomplete="name">
        </label>
        <label>
          联系电话
          <input name="phone" required maxlength="40" autocomplete="tel">
        </label>
        <label>
          邮箱(选填)
          <input name="email" type="email" maxlength="254" autocomplete="email">
        </label>
        <label>
          套餐
          <select name="packageId" required>
            <option value="">请选择套餐</option>
            <option value="classic">经典</option>
            <option value="garden">森系</option>
            <option value="destination">旅拍</option>
          </select>
        </label>
        <label>
          拍摄日期
          <input name="date" type="date" required>
        </label>
        <label>
          时段
          <select name="slot" required>
            <option value="">请选择时段</option>
            <option value="09:00">09:00</option>
            <option value="13:00">13:00</option>
            <option value="16:00">16:00</option>
          </select>
        </label>
        <label>
          备注
          <textarea name="note" maxlength="500"></textarea>
        </label>
        <button type="submit">提交预约申请</button>
        <p id="booking-result" role="status" aria-live="polite"></p>
      </form>
    </section>
  </main>
  <script src="/app.js" defer></script>
</body>
</html>

表单使用 required、maxlength 和 type="email" 改善填写体验,但这些属于客户端校验,用户可以绕过。因此服务端仍需自行限制长度、检查格式和确认选项是否有效。前后端的时段与套餐列表也应保持一致;若后续由后台动态维护,应改为从服务端读取可选项。

六、渲染作品并提交预约

在 public/app.js 中定义作品数据、按风格筛选,并发送预约请求。示例使用 DOM API 写入作品文字,避免把用户输入拼接进 innerHTML。

const works = [
  {
    title: '林间婚礼',
    style: 'garden',
    image: '/media/garden-session.webp',
    alt: '林间自然光婚纱作品'
  },
  {
    title: '城市夜景',
    style: 'city',
    image: '/media/city-session.webp',
    alt: '城市夜景婚纱作品'
  },
  {
    title: '经典棚拍',
    style: 'classic',
    image: '/media/classic-session.webp',
    alt: '经典棚拍婚纱作品'
  }
];

const worksNode = document.querySelector('#works');
const filterNode = document.querySelector('#style-filter');

function renderWorks(style) {
  worksNode.replaceChildren();

  const visibleWorks = style === 'all'
    ? works
    : works.filter((work) => work.style === style);

  for (const work of visibleWorks) {
    const card = document.createElement('article');
    const image = document.createElement('img');
    const title = document.createElement('h2');

    image.src = work.image;
    image.alt = work.alt;
    image.loading = 'lazy';

    title.textContent = work.title;
    card.append(image, title);
    worksNode.append(card);
  }
}

filterNode.addEventListener('change', () => {
  renderWorks(filterNode.value);
});

renderWorks('all');

const form = document.querySelector('#booking-form');
const result = document.querySelector('#booking-result');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  result.textContent = '正在提交…';

  const data = Object.fromEntries(new FormData(form).entries());

  try {
    const response = await fetch('/api/bookings', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });

    const body = await response.json();

    if (!response.ok) {
      result.textContent = body.error || '提交失败,请检查填写内容。';
      return;
    }

    result.textContent = body.message;
    form.reset();
  } catch {
    result.textContent = '网络暂时不可用,请稍后重试。';
  }
});

为了让作品列表正常显示,需要在 public/media/ 中放置与路径对应的图片文件。示例设置了 alt 替代文本和延迟加载;正式内容应为每张作品写准确描述,不要将关键词机械堆进替代文本。还应根据原站样式补充卡片布局、移动端适配和视觉状态,但这些样式不影响筛选与预约接口的基本流程。

作品信息写在浏览器端意味着访问者可以查看和修改它,不能用于保存秘密数据,也不适合频繁更新的内容管理。图片路径同样是公开地址。若作品涉及客户肖像,应在发布前确认取得适当授权,并提供撤下流程。

七、启动与验证

在项目根目录运行服务:

node server.js

浏览器打开 http://localhost:3000,检查作品筛选、表单必填提示和提交反馈。也可以用命令行测试接口;日期需替换为今天至未来 180 天内的真实日期:

curl -i http://localhost:3000/api/bookings \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "林女士",
    "phone": "+86 13800000000",
    "email": "[email protected]",
    "packageId": "garden",
    "date": "2026-09-20",
    "slot": "09:00",
    "note": "希望咨询外景安排"
  }'

示例请求日期只是格式演示;如果运行时它不在允许范围内,接口会按规则拒绝。首次成功通常返回 HTTP 201;字段不合法返回 HTTP 400;同一日期和时段已有未取消记录时返回 HTTP 409。服务器错误返回 HTTP 500,但响应不应包含数据库错误细节。

再次提交同一日期和时段,验证唯一约束是否生效。然后用非法套餐、无效日期、超长备注和错误电话格式进行测试。还应检查 SQLite 文件确实产生,重启服务后记录仍存在。不要只测试前端提示,因为直接调用接口的请求不会经过表单校验。

八、常见错误与处理方式

1. 只在前端判断日期和时段

前端控件可以被绕过,甚至请求可以完全不经过页面。业务规则必须在服务端重新校验,数据库还要负责解决并发冲突。

2. 用“先查询、后插入”代替唯一约束

单纯查询空档再写入,在并发请求下可能重复占位。应由数据库唯一索引兜底,并在发生冲突时返回明确的 409。若采用不同数据库,需按该数据库的事务与唯一约束机制实现。

3. 把预约申请说成最终确认

接口成功只表示记录已接收,不代表门店已确认场地、摄影师和套餐。页面文案、短信和后台状态应明确区分“待确认”“已确认”“已取消”。

4. 直接用访客输入生成 HTML

将用户姓名、备注等内容拼接到 innerHTML,可能引入跨站脚本风险。页面展示内容优先使用 textContent;后台模板输出数据时也要进行上下文正确的转义。输入长度限制不能代替输出编码。

5. 把数据库文件和源码一起公开

SQLite 文件含有姓名、电话等预约信息,绝不能放进静态资源目录,也不能通过 Web 服务器直接下载。示例把数据库文件放在项目根目录,并由 Express 静态中间件只发布 public。生产环境仍应确认反向代理、备份目录和文件权限没有意外暴露该文件。

6. 认为示例已包含完整防滥用能力

示例没有实现登录后台、验证码、速率限制、CSRF 防护、邮件或短信发送。若部署在可跨站提交的场景,需要结合域名、Cookie 和认证方式评估 CSRF 风险;公开接口应增加适当的频率限制和滥用监测。使用 HTTPS,配置合理的代理与安全响应头,并避免在日志中记录完整联系方式。

7. 把前端选项当作权威数据

套餐与时段可以被用户自行修改。服务端通过允许列表确认提交值合法,但示例的允许列表写死在代码中。上线后若需要临时关闭时段、设置节假日或区分摄影师,应以服务端日历和资源数据为准,并在提交时重新判断当前可用性。

九、上线前的安全与运维检查

  • 使用 HTTPS;不要通过明文连接传输联系方式。
  • 为预约数据库配置访问权限和定期备份,测试恢复流程;备份文件也按敏感数据保护。
  • 限制接口请求体大小,并根据业务增加请求频率控制、垃圾预约识别和告警。
  • 制定个人信息用途、保存期限、访问人员范围和删除流程。只收集预约所必需的信息,不要在备注中索要身份证号等无关敏感资料。
  • 管理端必须有身份验证与权限控制;不能因为示例未提供后台,就把数据库查询接口开放给公众。
  • 将错误详情写入受控服务端日志,不把 SQL、文件路径和堆栈返回给浏览器;同时避免日志长期保留不必要的个人信息。
  • 按真实资源关系设计冲突规则。多门店或多摄影师场景,唯一键可能需要包含门店或资源编号,复杂排班还需使用事务处理多项资源占用。

十、后续扩展建议

基础版本跑通后,可以分阶段扩展。第一步,将作品和套餐迁移到可管理的数据源,并保留服务端对套餐状态的校验。第二步,增加后台预约列表、状态流转、操作审计和取消规则,后台接口要经过认证并做权限检查。第三步,根据实际业务接入通知服务,但应处理发送失败、重复发送和消息模板中的个人信息。第四步,若要支持在线付款,应将支付状态和预约状态分开设计,通过可信的支付回调更新订单,不能只相信浏览器跳转结果。

对已有婚纱摄影源码而言,最稳妥的改造方式不是一次性重写全站,而是先确认原有页面结构、部署方式和内容来源,再把预约接口作为独立边界接入。作品筛选可以从静态数据开始;预约则必须由服务端校验并可靠保存。清楚区分“访客提交申请”和“门店确认档期”,再逐步补齐权限、备份与运营流程,才能避免页面看似完成、实际预约却重复或无法追踪的问题。

© 版权声明

相关文章