
最近刷短视频的时候看到一位中专学历的横漂老哥默默跑了十年剧组终于等来一个在大银幕上露脸的机会。评论区里有人感慨“梦想还是要有的”也有人把它当成一段励志故事看完就划走。但作为一个常年跟代码打交道的人我更在意的是另一件事在这个流量被算法控制的时代一个普通人想被别人看到除了等机会降临还能不能主动做点什么答案其实很朴素——做一个属于自己的作品集网站。不管你是演员、摄影师、设计师还是刚转行的程序员把作品、经历、联系方式沉淀到一个可访问的页面上就是你的“数字名片”。它不依赖平台推荐不需要等别人帮你传播只要有人打开链接就能完整看到你是谁、做过什么、怎么联系你。这篇文章我会带你从零搭建一个个人作品集网站包含前端展示页、联系表单、后台作品管理和留言管理最后还能部署到服务器上线。全程以 Python Flask 为例新手也能跟着一步一步做下来。1. 背景与核心概念1.1 为什么普通人需要个人作品集网站很多人会觉得想展示自己发朋友圈、发短视频不就行了真实情况是社交平台的内容很容易被时间线冲散也很容易被算法限流。你辛苦整理的作品照片可能发出去几个小时后就没人看到了。而且平台之间互相割裂抖音放视频、小红书放图片、朋友圈放日常访客想完整了解你得在不同 App 之间来回跳转体验非常零散。个人作品集网站能解决的核心问题就是“集中展示”。它就像一个完全属于你自己的展示柜所有内容由你自己控制不会被平台折叠不会因为限流而消失。任何人拿到一个网址就能看到结构化的个人介绍、精选作品、技能标签和联系方式。对演员、设计师、摄影师这类靠作品说话的行业来说这种专业感甚至比简历本身更重要。另外个人网站还有一个容易被忽略的价值积累。社交平台的内容属于平台账号注销、平台倒闭内容就没了。而自己的网站数据和文件都在自己手里可以长期沉淀也可以随时迁移。十年后回看这就是一条完整的成长轨迹。1.2 作品集网站应该包含哪些功能一个能真正派上用场的作品集网站不需要花哨但功能要完整。我从实际使用场景出发把核心功能拆成五块第一是个人简介。首页要有一句话说明你是谁、在做什么、有什么代表性标签。访客打开网站的前三秒就要能判断出你的方向。第二是作品展示。以卡片形式展示图片、视频封面、项目简介最好能按分类区分比如影视、广告、短视频。第三是技能标签。用一组标签快速展示你的能力范围方便导演、制片人或者客户第一时间了解你擅长什么。第四是联系表单。访客想合作时有一个明确的入口可以留下姓名、邮箱和留言。这个信息会保存到后台方便你定期查看。第五是后台管理。你不可能每次加新作品都去改代码所以需要一个简单的登录后台实现在网页上发布新作品、删除旧作品、查看访客留言。1.3 为什么选择 Flask 而不是其他方案实现一个这样的网站技术方案其实很多。纯静态 HTML 页面固然简单但作品没法动态管理留言功能也不好做。用 Vue 或 React 做前后端分离对新手来说学习成本又高了一截。所以我选择了 Flask 这个轻量级 Python Web 框架。Flask 的优点在于“简单直接”。一个 Python 文件就能写出所有后端接口配合 Jinja2 模板引擎可以直接在 HTML 里渲染数据不需要额外搭建前后端分离工程。数据库用 SQLite它是 Python 自带的零配置、免安装非常适合个人网站这种低并发场景。部署方案也很成熟Gunicorn 加 Nginx 是社区里非常常见的组合网上资料多遇到问题很容易排查。2. 环境准备与版本说明2.1 本地开发环境在开始写代码之前先确认你的电脑已经有 Python 环境。本项目以 Python 3.10 为例Python 3.9 以上的版本基本都可以正常运行。操作系统方面Windows 10/11、macOS、Ubuntu 20.04 及以上都可以。本文中的命令在 Windows 下略有差异我会在对应位置说明。IDE 推荐 VS Code也可以使用 PyCharm。VS Code 比较轻量打开项目更快插件生态也够用。2.2 依赖版本清单软件/库版本建议说明Python3.9推荐 3.10 或 3.11Flask2.3.x本文示例以 Flask 2.3.3 为例Gunicorn21.x仅部署到 Linux 服务器时需要SQLitePython 自带无需单独安装Nginx1.18仅部署时需要版本不需要完全一致核心思路是统一的。如果你用的是 Flask 3.x代码中的 API 基本兼容只有个别扩展包的写法可能有差异。遇到问题先看报错信息再搜对应版本的关键词。2.3 检查 Python 环境打开终端Windows 下是 CMD 或 PowerShell执行以下命令python --version pip --version如果能正常输出版本号说明 Python 环境没问题。如果提示python不是内部或外部命令可能是安装时没有勾选“Add Python to PATH”需要重新安装或者手动配置环境变量。3. 功能设计与项目结构3.1 用户故事与页面规划在写代码之前我们先用一句话描述这个网站的核心使用流程访客打开网站浏览个人介绍和作品对创作者产生兴趣后通过联系表单留下合作信息创作者登录后台发布新作品、删除旧作品、查看收到的留言。围绕这条流程我规划了四个页面页面功能技术要点首页个人简介、作品列表、联系表单Jinja2 循环渲染、fetch 提交表单关于我个人经历、技能标签静态页面登录页管理员账号密码登录Session 会话后台管理发布作品、删除作品、查看留言登录校验、文件上传首页是核心页面承担了“展示”和“转化”两个任务。后台管理页只有登录后才能访问。3.2 数据库表设计本项目的数据库使用 SQLite存储三张表作品表works保存作品的基本信息包括标题、分类、封面图片路径和简介。留言表messages保存访客提交的姓名、邮箱和留言内容。管理员表admin保存后台登录的用户名和密码哈希值密码不能明文存储。表名字段说明worksid, title, category, image_url, description, created_at作品列表messagesid, name, email, content, created_at访客留言adminid, username, password_hash管理员账号创建表的 SQL 语句会在后端代码里通过executescript一次性初始化。3.3 项目目录结构项目目录结构如下后续代码都按这个结构存放portfolio/ ├── app.py # Flask 主程序 ├── requirements.txt # 依赖清单 ├── static/ │ ├── css/ │ │ └── style.css # 页面样式 │ ├── js/ │ │ └── main.js # 前端交互 │ └── uploads/ # 上传的作品图片 ├── templates/ │ ├── base.html # 页面骨架 │ ├── index.html # 首页 │ ├── about.html # 关于我 │ ├── login.html # 登录页 │ └── admin.html # 后台管理 └── instance/ └── portfolio.sqlite3 # 数据库文件自动生成instance目录是 Flask 约定存放实例数据的位置数据库文件会自动创建。static/uploads目录用来保存后台上传的图片同样会自动创建。4. 后端核心实现Flask 应用4.1 创建依赖清单在项目根目录创建requirements.txt内容如下flask2.3.3如果需要在 Linux 服务器上部署再加上 Gunicornflask2.3.3 gunicorn21.2.04.2 编写 Flask 主程序下面这是核心文件app.py包含数据库初始化、页面路由、登录校验、作品发布和留言接口。代码较长但每个部分我都会在代码后面解释。# 文件路径portfolio/app.py import os import sqlite3 import uuid from functools import wraps from flask import ( Flask, g, render_template, request, redirect, url_for, session, jsonify, flash ) from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename app Flask(__name__) app.config[SECRET_KEY] os.environ.get(SECRET_KEY, dev-secret-key-change-me) app.config[DATABASE] os.path.join(app.instance_path, portfolio.sqlite3) app.config[UPLOAD_FOLDER] os.path.join(app.static_folder, uploads) app.config[MAX_CONTENT_LENGTH] 8 * 1024 * 1024 ALLOWED_EXTENSIONS {png, jpg, jpeg, gif, webp, mp4, webm} def allowed_file(filename): return . in filename and filename.rsplit(., 1)[1].lower() in ALLOWED_EXTENSIONS def get_db(): if db not in g: g.db sqlite3.connect(app.config[DATABASE]) g.db.row_factory sqlite3.Row return g.db app.teardown_appcontext def close_db(exception): db g.pop(db, None) if db is not None: db.close() def init_db(): os.makedirs(app.instance_path, exist_okTrue) os.makedirs(app.config[UPLOAD_FOLDER], exist_okTrue) conn sqlite3.connect(app.config[DATABASE]) conn.row_factory sqlite3.Row conn.executescript( CREATE TABLE IF NOT EXISTS works ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, category TEXT NOT NULL, image_url TEXT DEFAULT , description TEXT DEFAULT , created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS admin ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL ); ) count conn.execute(SELECT COUNT(*) FROM admin).fetchone()[0] if count 0: conn.execute( INSERT INTO admin (username, password_hash) VALUES (?, ?), (admin, generate_password_hash(admin123)) ) print(已创建默认管理员admin / admin123登录后台后请尽快修改) conn.commit() conn.close() def login_required(view): wraps(view) def wrapped(*args, **kwargs): if user_id not in session: flash(请先登录) return redirect(url_for(login)) return view(*args, **kwargs) return wrapped先解释几个关键点。SECRET_KEY是 Flask 签名 Session 时使用的密钥。生产环境绝对不能使用代码里这个默认值而应该通过环境变量传入防止 Session 被伪造。MAX_CONTENT_LENGTH限制上传文件最大为 8MB避免有人传超大文件拖垮服务器。allowed_file函数用来校验上传文件的后缀名防止上传可执行文件。get_db使用了 Flask 的g对象同一个请求周期内复用同一个数据库连接请求结束后通过close_db自动关闭。init_db在首次启动时创建表如果没有管理员账号会自动创建一个默认账号admin / admin123并打印提示。接着是页面路由和接口# 文件路径portfolio/app.py续 app.route(/) def index(): db get_db() works db.execute(SELECT * FROM works ORDER BY id DESC).fetchall() return render_template(index.html, worksworks) app.route(/about) def about(): return render_template(about.html) app.route(/api/message, methods[POST]) def add_message(): data request.get_json(silentTrue) or request.form name (data.get(name) or ).strip() email (data.get(email) or ).strip() content (data.get(content) or ).strip() if not name or not email or not content: return jsonify({code: 400, message: 姓名、邮箱和内容不能为空}), 400 if not in email: return jsonify({code: 400, message: 邮箱格式不正确}), 400 db get_db() db.execute( INSERT INTO