feat: add public homepage and downloads

This commit is contained in:
QiuSW
2026-07-06 23:18:08 +08:00
parent 8283573ddc
commit f158a08b9d
18 changed files with 1166 additions and 96 deletions
+62 -1
View File
@@ -1,3 +1,64 @@
from django.contrib import admin
# Register your models here.
from .models import DownloadRelease
@admin.register(DownloadRelease)
class DownloadReleaseAdmin(admin.ModelAdmin):
list_display = (
"platform",
"version",
"is_current",
"download_source",
"sha256_short",
"updated_at",
)
list_filter = ("platform", "is_current")
search_fields = ("version", "sha256", "external_url", "file")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(
"版本信息",
{
"fields": (
"platform",
"version",
"is_current",
"release_notes",
)
},
),
(
"下载配置",
{
"fields": (
"file",
"external_url",
"sha256",
)
},
),
(
"时间",
{
"fields": (
"created_at",
"updated_at",
)
},
),
)
@admin.display(description="下载来源")
def download_source(self, obj):
if obj.external_url:
return "外部链接"
if obj.file:
return "本地文件"
return "未配置"
@admin.display(description="SHA256")
def sha256_short(self, obj):
if not obj.sha256:
return "-"
return f"{obj.sha256[:12]}..."
+37
View File
@@ -0,0 +1,37 @@
# Generated by Django 5.2.15 on 2026-07-06 09:48
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DownloadRelease',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=32, verbose_name='平台')),
('version', models.CharField(max_length=64, verbose_name='版本')),
('file', models.FileField(blank=True, upload_to='downloads/', verbose_name='安装包文件')),
('external_url', models.URLField(blank=True, verbose_name='外部下载地址')),
('sha256', models.CharField(blank=True, max_length=64, validators=[django.core.validators.RegexValidator(message='SHA256 必须是 64 位十六进制字符。', regex='^[A-Fa-f0-9]{64}$')], verbose_name='SHA256')),
('is_current', models.BooleanField(default=False, verbose_name='当前版本')),
('release_notes', models.TextField(blank=True, verbose_name='发布说明')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'db_table': 'download_release',
'verbose_name': '客户端下载版本',
'verbose_name_plural': '客户端下载版本',
'ordering': ('platform', '-is_current', '-created_at', '-id'),
'indexes': [models.Index(fields=['platform', 'is_current'], name='dl_platform_current_idx')],
},
),
]
+70 -2
View File
@@ -1,3 +1,71 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models, transaction
# Create your models here.
class DownloadRelease(models.Model):
class Platform(models.TextChoices):
WINDOWS = "windows", "Windows"
MACOS = "macos", "macOS"
LINUX = "linux", "Linux"
platform = models.CharField("平台", max_length=32, choices=Platform.choices)
version = models.CharField("版本", max_length=64)
file = models.FileField("安装包文件", upload_to="downloads/", blank=True)
external_url = models.URLField("外部下载地址", blank=True)
sha256 = models.CharField(
"SHA256",
max_length=64,
blank=True,
validators=[
RegexValidator(
regex=r"^[A-Fa-f0-9]{64}$",
message="SHA256 必须是 64 位十六进制字符。",
)
],
)
is_current = models.BooleanField("当前版本", default=False)
release_notes = models.TextField("发布说明", blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "download_release"
verbose_name = "客户端下载版本"
verbose_name_plural = "客户端下载版本"
ordering = ("platform", "-is_current", "-created_at", "-id")
indexes = [
models.Index(
fields=("platform", "is_current"),
name="dl_platform_current_idx",
),
]
def __str__(self):
return f"{self.get_platform_display()} {self.version}"
@property
def download_url(self) -> str:
if self.external_url:
return self.external_url
if self.file:
return self.file.url
return ""
@property
def has_download(self) -> bool:
return bool(self.download_url)
def clean(self):
super().clean()
if not self.file and not self.external_url:
raise ValidationError("安装包文件和外部下载地址至少填写一个。")
def save(self, *args, **kwargs):
with transaction.atomic():
super().save(*args, **kwargs)
if self.is_current:
type(self).objects.filter(
platform=self.platform,
is_current=True,
).exclude(pk=self.pk).update(is_current=False)
+634
View File
@@ -0,0 +1,634 @@
:root {
--cm-paper: #F5F7FB;
--cm-card: #FFFFFF;
--cm-ink-strong: #161C2B;
--cm-ink: #1A2030;
--cm-ink-soft: #5A6478;
--cm-faint: #8A93A6;
--cm-line: #DCE1EC;
--cm-indigo: #5B4BEF;
--cm-indigo-deep: #3A2FB0;
--cm-amber: #E8A02D;
--cm-amber-ink: #C9821A;
--cm-mint: #3FCB7E;
--cm-chip-indigo: #ECEBFF;
--cm-chip-amber: #FBEFD9;
--cm-band-1: #1B1740;
--cm-band-2: #2A1E5E;
--cm-radius: 8px;
--cm-radius-lg: 8px;
--cm-font-cjk: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--cm-font-latin: "Space Grotesk", system-ui, sans-serif;
--cm-font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
--cm-btn: linear-gradient(#6455F2, #4335CF);
--cm-thread: linear-gradient(#5B4BEF, #E8A02D);
--cm-shadow: 0 18px 42px rgba(22, 28, 43, 0.09);
--cmhub-bg: var(--cm-paper);
--cmhub-ink: var(--cm-ink);
--cmhub-line: var(--cm-line);
--cmhub-accent: var(--cm-indigo);
--bs-body-font-family: var(--cm-font-cjk);
--bs-body-color: var(--cm-ink);
--bs-body-bg: var(--cm-paper);
--bs-border-color: var(--cm-line);
--bs-link-color: var(--cm-indigo);
--bs-link-hover-color: var(--cm-indigo-deep);
}
body {
background: var(--cm-paper);
color: var(--cm-ink);
}
.navbar {
border-bottom: 1px solid var(--cm-line);
background: rgba(255, 255, 255, 0.92);
}
.navbar-brand {
align-items: center;
color: var(--cm-ink-strong);
display: inline-flex;
gap: 10px;
}
.navbar-brand::before {
background:
linear-gradient(to top, var(--cm-indigo) 46%, transparent 46%) 0 100% / 5px 18px no-repeat,
linear-gradient(to top, var(--cm-indigo) 68%, transparent 68%) 9px 100% / 5px 18px no-repeat,
linear-gradient(to top, var(--cm-amber) 92%, transparent 92%) 18px 100% / 5px 18px no-repeat;
border: 1px solid var(--cm-line);
border-radius: 7px;
content: "";
height: 28px;
width: 32px;
}
.cmhub-shell {
margin: 0 auto;
max-width: 1080px;
padding: 40px 16px;
}
.cmhub-panel {
background: var(--cm-card);
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
box-shadow: var(--cm-shadow);
margin: 0 auto;
max-width: 440px;
padding: 28px;
}
.cmhub-panel form p {
margin-bottom: 16px;
}
.cmhub-panel label {
color: var(--cm-ink-strong);
display: block;
font-weight: 600;
margin-bottom: 6px;
}
.cmhub-panel input {
border: 1px solid #B9C2CF;
border-radius: var(--cm-radius);
min-height: 40px;
padding: 8px 10px;
width: 100%;
}
.cmhub-panel .errorlist {
color: #B42318;
margin: 0 0 12px;
padding-left: 18px;
}
.btn-primary {
--bs-btn-bg: var(--cm-indigo);
--bs-btn-border-color: var(--cm-indigo);
--bs-btn-hover-bg: var(--cm-indigo-deep);
--bs-btn-hover-border-color: var(--cm-indigo-deep);
--bs-btn-active-bg: var(--cm-indigo-deep);
--bs-btn-active-border-color: var(--cm-indigo-deep);
background-image: var(--cm-btn);
}
.btn-outline-secondary {
--bs-btn-color: var(--cm-ink);
--bs-btn-border-color: var(--cm-line);
--bs-btn-hover-bg: var(--cm-chip-indigo);
--bs-btn-hover-border-color: var(--cm-indigo);
--bs-btn-hover-color: var(--cm-indigo-deep);
}
.metric,
.cmhub-surface {
background: var(--cm-card);
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
box-shadow: 0 10px 30px rgba(22, 28, 43, 0.05);
}
.metric {
padding: 18px;
}
.metric .display-6,
.metric .h5,
.cmhub-surface h2 {
color: var(--cm-ink-strong);
}
.metric .display-6 {
color: var(--cm-amber-ink);
font-family: var(--cm-font-mono);
}
.cmhub-surface {
padding: 20px;
}
.key-value,
code,
.font-monospace {
font-family: var(--cm-font-mono);
word-break: break-all;
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--cm-ink);
--bs-table-border-color: var(--cm-line);
}
.badge.text-bg-secondary {
background-color: var(--cm-ink-soft) !important;
}
.badge.text-bg-warning {
background-color: var(--cm-chip-amber) !important;
color: var(--cm-amber-ink) !important;
}
.badge.text-bg-success {
background-color: var(--cm-mint) !important;
color: #0E3B24 !important;
}
.home-page .navbar {
position: sticky;
top: 0;
z-index: 10;
}
.home-main {
overflow: hidden;
}
.home-container {
margin: 0 auto;
max-width: 1180px;
padding: 0 20px;
}
.home-hero {
min-height: calc(100vh - 56px);
padding: 64px 0 34px;
}
.home-hero-grid {
align-items: center;
display: grid;
gap: 42px;
grid-template-columns: minmax(0, 0.96fr) minmax(360px, 1.04fr);
}
.home-eyebrow {
color: var(--cm-indigo);
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 14px;
}
.home-hero h1,
.home-section-title,
.home-band-title {
color: var(--cm-ink-strong);
font-family: var(--cm-font-latin), var(--cm-font-cjk);
font-weight: 700;
letter-spacing: 0;
}
.home-hero h1 {
font-size: clamp(2.35rem, 5vw, 3.55rem);
line-height: 1.05;
margin-bottom: 20px;
}
.home-lead {
color: var(--cm-ink-soft);
font-size: 1.08rem;
line-height: 1.75;
max-width: 620px;
}
.home-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.home-proof {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 24px;
}
.home-chip {
align-items: center;
background: var(--cm-card);
border: 1px solid var(--cm-line);
border-radius: 999px;
color: var(--cm-ink-soft);
display: inline-flex;
font-size: 0.88rem;
gap: 8px;
min-height: 34px;
padding: 6px 12px;
}
.home-chip::before {
background: var(--cm-mint);
border-radius: 999px;
content: "";
height: 8px;
width: 8px;
}
.home-stage {
background: var(--cm-card);
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius-lg);
box-shadow: var(--cm-shadow);
padding: 18px;
}
.home-product-shot {
background:
linear-gradient(135deg, rgba(91, 75, 239, 0.08), rgba(232, 160, 45, 0.16)),
var(--cm-paper);
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
min-height: 160px;
padding: 18px;
}
.home-product-frame {
align-items: center;
background: #FBFCFF;
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
display: flex;
gap: 14px;
min-height: 120px;
padding: 14px;
}
.home-product-image {
align-items: center;
background: linear-gradient(145deg, #E8EBF7, #FFFFFF);
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
color: var(--cm-indigo);
display: flex;
flex: 0 0 108px;
font-weight: 700;
height: 92px;
justify-content: center;
}
.home-product-copy {
color: var(--cm-ink-soft);
font-size: 0.9rem;
line-height: 1.6;
}
.home-thread {
background: var(--cm-thread);
border-radius: 999px;
height: 56px;
margin: 16px auto;
position: relative;
width: 4px;
}
.home-thread::before,
.home-thread::after {
background: var(--cm-card);
border: 2px solid var(--cm-line);
border-radius: 999px;
content: "";
height: 12px;
left: -4px;
position: absolute;
width: 12px;
}
.home-thread::before {
top: -2px;
}
.home-thread::after {
bottom: -2px;
}
.home-output-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.home-output {
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
padding: 14px;
}
.home-output-title {
color: var(--cm-ink-strong);
font-weight: 700;
margin-bottom: 10px;
}
.home-output-text {
color: var(--cm-ink-soft);
font-size: 0.92rem;
line-height: 1.6;
}
.home-point-pill {
background: var(--cm-chip-amber);
border-radius: 999px;
color: var(--cm-amber-ink);
display: inline-flex;
font-family: var(--cm-font-mono);
font-size: 0.84rem;
font-weight: 700;
margin-top: 12px;
padding: 5px 10px;
}
.home-section {
padding: 70px 0;
}
.home-section-heading {
display: flex;
justify-content: space-between;
align-items: end;
gap: 24px;
margin-bottom: 28px;
}
.home-section-title {
font-size: clamp(1.9rem, 4vw, 2.4rem);
line-height: 1.15;
margin: 0;
}
.home-section-copy {
color: var(--cm-ink-soft);
line-height: 1.7;
margin: 0;
max-width: 520px;
}
.home-steps,
.home-cards {
display: grid;
gap: 16px;
}
.home-steps {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.home-cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.home-step,
.home-card,
.home-download-box {
background: var(--cm-card);
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
padding: 22px;
}
.home-step-num {
color: var(--cm-indigo);
font-family: var(--cm-font-mono);
font-size: 0.86rem;
font-weight: 700;
margin-bottom: 18px;
}
.home-step h3,
.home-card h3,
.home-download-box h3 {
color: var(--cm-ink-strong);
font-size: 1.12rem;
font-weight: 700;
margin-bottom: 10px;
}
.home-step p,
.home-card p,
.home-download-box p {
color: var(--cm-ink-soft);
line-height: 1.7;
margin-bottom: 0;
}
.home-card {
min-height: 210px;
}
.home-card-icon {
align-items: center;
background: var(--cm-chip-indigo);
border-radius: var(--cm-radius);
color: var(--cm-indigo-deep);
display: inline-flex;
font-weight: 700;
height: 44px;
justify-content: center;
margin-bottom: 22px;
width: 44px;
}
.home-band {
background: linear-gradient(135deg, var(--cm-band-1), var(--cm-band-2));
color: #FFFFFF;
padding: 62px 0;
}
.home-band-grid {
align-items: center;
display: grid;
gap: 28px;
grid-template-columns: 1.1fr 0.9fr;
}
.home-band-title {
color: #FFFFFF;
font-size: clamp(1.9rem, 4vw, 2.35rem);
line-height: 1.18;
margin-bottom: 14px;
}
.home-band p {
color: rgba(255, 255, 255, 0.78);
line-height: 1.75;
margin-bottom: 0;
}
.home-band-meter {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: var(--cm-radius);
padding: 22px;
}
.home-band-meter strong {
color: var(--cm-amber);
display: block;
font-family: var(--cm-font-mono);
font-size: 2.2rem;
line-height: 1;
margin-bottom: 8px;
}
.home-download-grid {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.74fr);
}
.home-release-meta {
display: grid;
gap: 12px;
margin-top: 18px;
}
.home-release-row {
align-items: start;
display: grid;
gap: 10px;
grid-template-columns: 96px minmax(0, 1fr);
}
.home-release-row span {
color: var(--cm-faint);
}
.home-release-row strong,
.home-release-row code {
color: var(--cm-ink);
min-width: 0;
}
.home-notice {
background: var(--cm-chip-amber);
border: 1px solid rgba(232, 160, 45, 0.34);
border-radius: var(--cm-radius);
color: #6F4708;
line-height: 1.7;
margin-top: 18px;
padding: 14px 16px;
}
.home-links {
display: grid;
gap: 12px;
}
.home-link-row {
align-items: center;
border: 1px solid var(--cm-line);
border-radius: var(--cm-radius);
color: var(--cm-ink);
display: flex;
justify-content: space-between;
min-height: 54px;
padding: 12px 14px;
text-decoration: none;
}
.home-link-row:hover {
background: var(--cm-chip-indigo);
color: var(--cm-indigo-deep);
}
@media (max-width: 991.98px) {
.home-hero-grid,
.home-band-grid,
.home-download-grid {
grid-template-columns: 1fr;
}
.home-steps {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 575.98px) {
.cmhub-shell {
padding: 28px 12px;
}
.home-container {
padding: 0 14px;
}
.home-hero {
min-height: auto;
padding: 44px 0 28px;
}
.home-hero-grid,
.home-output-grid,
.home-steps,
.home-cards {
grid-template-columns: 1fr;
}
.home-section-heading {
align-items: start;
flex-direction: column;
}
.home-actions .btn {
width: 100%;
}
.home-product-frame,
.home-release-row {
grid-template-columns: 1fr;
}
.home-product-frame {
align-items: stretch;
display: grid;
}
.home-product-image {
width: 100%;
}
}
+4 -71
View File
@@ -6,78 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}cmhub{% endblock %}</title>
<link href="{% static 'portal/vendor/bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<style>
:root {
--cmhub-bg: #f6f7f9;
--cmhub-ink: #17202a;
--cmhub-line: #d8dee6;
--cmhub-accent: #0f766e;
}
body {
background: var(--cmhub-bg);
color: var(--cmhub-ink);
}
.navbar {
border-bottom: 1px solid var(--cmhub-line);
background: #ffffff;
}
.cmhub-shell {
max-width: 960px;
margin: 0 auto;
padding: 40px 16px;
}
.cmhub-panel {
max-width: 440px;
margin: 0 auto;
border: 1px solid var(--cmhub-line);
border-radius: 8px;
background: #ffffff;
padding: 28px;
}
.cmhub-panel form p {
margin-bottom: 16px;
}
.cmhub-panel label {
display: block;
margin-bottom: 6px;
font-weight: 600;
}
.cmhub-panel input {
width: 100%;
min-height: 40px;
border: 1px solid #b9c2cf;
border-radius: 6px;
padding: 8px 10px;
}
.cmhub-panel .errorlist {
margin: 0 0 12px;
padding-left: 18px;
color: #b42318;
}
.btn-primary {
--bs-btn-bg: var(--cmhub-accent);
--bs-btn-border-color: var(--cmhub-accent);
--bs-btn-hover-bg: #0b5f59;
--bs-btn-hover-border-color: #0b5f59;
}
.metric {
border: 1px solid var(--cmhub-line);
border-radius: 8px;
background: #ffffff;
padding: 18px;
}
.cmhub-surface {
border: 1px solid var(--cmhub-line);
border-radius: 8px;
background: #ffffff;
padding: 20px;
}
.key-value {
word-break: break-all;
}
</style>
<link href="{% static 'portal/brand.css' %}" rel="stylesheet">
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
<nav class="navbar navbar-expand">
<div class="container">
<a class="navbar-brand fw-semibold" href="{% url 'portal-home' %}">cmhub</a>
@@ -102,6 +33,7 @@
</div>
</div>
</nav>
{% block body %}
<main class="cmhub-shell">
{% if messages %}
<div class="mb-3">
@@ -112,5 +44,6 @@
{% endif %}
{% block content %}{% endblock %}
</main>
{% endblock %}
</body>
</html>
+199
View File
@@ -0,0 +1,199 @@
{% extends "portal/base.html" %}
{% block title %}cmhub AI 电商生成台{% endblock %}
{% block body_class %}home-page{% endblock %}
{% block body %}
<main class="home-main">
<section class="home-hero">
<div class="home-container home-hero-grid">
<div>
<div class="home-eyebrow">AI 生成 / 点数计费 / 桌面端接入</div>
<h1>cmhub AI 电商生成台</h1>
<p class="home-lead">
把生成标题、生成主图能力封装成稳定的 HTTP API。桌面端填入 API Key 后按本地点数扣费,
余额不足直接拦截,失败自动退点。
</p>
<div class="home-actions">
{% if user.is_authenticated %}
<a class="btn btn-primary btn-lg" href="{% url 'portal-dashboard' %}">进入控制台</a>
{% else %}
<a class="btn btn-primary btn-lg" href="{% url 'portal-signup' %}">免费注册</a>
<a class="btn btn-outline-secondary btn-lg" href="{% url 'portal-login' %}">登录</a>
{% endif %}
{% if current_release and current_release.has_download %}
<a class="btn btn-outline-secondary btn-lg" href="{{ current_release.download_url }}">下载客户端</a>
{% else %}
<a class="btn btn-outline-secondary btn-lg disabled" aria-disabled="true" href="#download">客户端下载暂未发布</a>
{% endif %}
</div>
<div class="home-proof" aria-label="服务要点">
<span class="home-chip">注册即可用</span>
<span class="home-chip">按点数扣点</span>
<span class="home-chip">API Key 接入</span>
</div>
</div>
<div class="home-stage" aria-label="生成台示意">
<div class="home-product-shot">
<div class="home-product-frame">
<div class="home-product-image">商品图</div>
<div class="home-product-copy">
轻量夹克,春秋外套,通勤穿搭,图片清晰,适合电商标题与主图优化。
</div>
</div>
</div>
<div class="home-thread" aria-hidden="true"></div>
<div class="home-output-grid">
<div class="home-output">
<div class="home-output-title">吸睛标题</div>
<div class="home-output-text">春秋通勤轻量夹克,防风舒适,男女日常外套</div>
<span class="home-point-pill">-2 点</span>
</div>
<div class="home-output">
<div class="home-output-title">生成主图</div>
<div class="home-output-text">保留商品主体,生成干净背景和电商展示构图。</div>
<span class="home-point-pill">-12 点</span>
</div>
</div>
</div>
</div>
</section>
<section class="home-section">
<div class="home-container">
<div class="home-section-heading">
<h2 class="home-section-title">四步接入桌面端</h2>
<p class="home-section-copy">从账号到调用链路保持简单:账号、点数、API Key 和客户端配置各司其职。</p>
</div>
<div class="home-steps">
<div class="home-step">
<div class="home-step-num">01</div>
<h3>注册账号</h3>
<p>邮箱注册后直接登录,系统创建 0 点钱包,不赠点、不写虚假流水。</p>
</div>
<div class="home-step">
<div class="home-step-num">02</div>
<h3>充值点数</h3>
<p>扫码创建充值订单,到账后按汇率入账到本地点数账本。</p>
</div>
<div class="home-step">
<div class="home-step-num">03</div>
<h3>生成 API Key</h3>
<p>在控制台生成 Key,明文只显示一次,后续列表只展示 prefix。</p>
</div>
<div class="home-step">
<div class="home-step-num">04</div>
<h3>填入桌面端</h3>
<p>客户端使用能力别名调用标题或图片接口,按规则扣点。</p>
</div>
</div>
</div>
</section>
<section class="home-section" id="api">
<div class="home-container">
<div class="home-section-heading">
<h2 class="home-section-title">面向电商流程的两类能力</h2>
<p class="home-section-copy">对外只暴露能力别名,不暴露底层供应商 SKU、URL、密钥或 extra_body。</p>
</div>
<div class="home-cards">
<article class="home-card">
<div class="home-card-icon">文</div>
<h3>生成标题</h3>
<p>按商品信息、提示词和能力别名生成标题。敏感词命中会在扣点和上游调用前拦截。</p>
</article>
<article class="home-card">
<div class="home-card-icon">图</div>
<h3>生成主图(改图)</h3>
<p>支持需要原图的图片能力,图片输入先做公网地址和大小校验,结果保存后返回 URL。</p>
</article>
</div>
</div>
</section>
<section class="home-band">
<div class="home-container home-band-grid">
<div>
<h2 class="home-band-title">点数余额是唯一权威账本</h2>
<p>
充值成功后写入本地点数;每次生成先预扣,成功确认,失败退点。调用链路不实时查询支付系统。
</p>
</div>
<div class="home-band-meter">
<strong>本地扣点</strong>
<p>余额不足返回“点数不足,请先充值”,不会继续调用上游模型。</p>
</div>
</div>
</section>
<section class="home-section" id="download">
<div class="home-container">
<div class="home-section-heading">
<h2 class="home-section-title">下载桌面端</h2>
<p class="home-section-copy">下载走 HTTPS,页面展示 SHA256 供校验;安装包前期由服务器 media 目录托管,生产 Nginx 直接服务。</p>
</div>
<div class="home-download-grid">
<div class="home-download-box">
{% if current_release and current_release.has_download %}
<h3>Windows 客户端 {{ current_release.version }}</h3>
<p>当前版本已发布,可下载后填入 cmhub API Key 使用。</p>
<div class="home-actions">
<a class="btn btn-primary" href="{{ current_release.download_url }}">下载客户端</a>
{% if user.is_authenticated %}
<a class="btn btn-outline-secondary" href="{% url 'portal-apikeys' %}">管理 API Key</a>
{% else %}
<a class="btn btn-outline-secondary" href="{% url 'portal-signup' %}">注册账号</a>
{% endif %}
</div>
<div class="home-release-meta">
<div class="home-release-row">
<span>版本</span>
<strong>{{ current_release.version }}</strong>
</div>
<div class="home-release-row">
<span>SHA256</span>
<code>{{ current_release.sha256|default:"未提供" }}</code>
</div>
{% if current_release.release_notes %}
<div class="home-release-row">
<span>说明</span>
<strong>{{ current_release.release_notes|linebreaksbr }}</strong>
</div>
{% endif %}
</div>
<div class="home-notice">
首期安装包可能未签名。Windows 若提示未知发布者,请先核对 SHA256,再选择“仍要运行”;正式代码签名后会更新版本说明。
</div>
{% else %}
<h3>客户端暂未发布</h3>
<p>后台尚未配置当前 Windows 版本。发布后这里会展示版本号、下载按钮、SHA256 和版本说明。</p>
<div class="home-actions">
<a class="btn btn-primary disabled" aria-disabled="true" href="#">暂未发布</a>
{% if user.is_authenticated %}
<a class="btn btn-outline-secondary" href="{% url 'portal-dashboard' %}">进入控制台</a>
{% else %}
<a class="btn btn-outline-secondary" href="{% url 'portal-signup' %}">先注册账号</a>
{% endif %}
</div>
{% endif %}
</div>
<div class="home-download-box">
<h3>接口与控制台</h3>
<div class="home-links">
{% if user.is_authenticated %}
<a class="home-link-row" href="{% url 'portal-dashboard' %}"><span>控制台</span><span>余额与记录</span></a>
<a class="home-link-row" href="{% url 'portal-models' %}"><span>可用模型</span><span>能力别名与价格</span></a>
<a class="home-link-row" href="{% url 'portal-apikeys' %}"><span>API Key</span><span>创建与吊销</span></a>
{% else %}
<a class="home-link-row" href="{% url 'portal-signup' %}"><span>注册</span><span>创建账号</span></a>
<a class="home-link-row" href="{% url 'portal-login' %}"><span>登录</span><span>进入控制台</span></a>
<a class="home-link-row" href="#api"><span>API 能力</span><span>标题 / 图片</span></a>
{% endif %}
</div>
</div>
</div>
</div>
</section>
</main>
{% endblock %}
+70
View File
@@ -18,6 +18,7 @@ from apps.billing.models import (
PricingRule,
RechargeOrder,
)
from apps.portal.models import DownloadRelease
from apps.users.models import ApiKey, UserWallet
@@ -110,6 +111,25 @@ class PortalAccountFlowTests(TestCase):
ai_model=ai_model,
)
def create_download_release(
self,
*,
version: str = "1.0.0",
platform: str = DownloadRelease.Platform.WINDOWS,
is_current: bool = True,
external_url: str = "https://download.example.com/cmhub-desktop.exe",
sha256: str = "a" * 64,
release_notes: str = "首版 Windows 客户端",
) -> DownloadRelease:
return DownloadRelease.objects.create(
platform=platform,
version=version,
is_current=is_current,
external_url=external_url,
sha256=sha256,
release_notes=release_notes,
)
def assert_nav_link_active(self, response, *, href: str, label: str):
html = response.content.decode("utf-8")
self.assertIn(
@@ -211,6 +231,56 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.status_code, 403)
def test_homepage_is_public_and_shows_anonymous_onboarding_without_release(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.resolver_match.url_name, "portal-home")
self.assertContains(response, "cmhub AI 电商生成台")
self.assertContains(response, "免费注册")
self.assertContains(response, "登录")
self.assertContains(response, "客户端暂未发布")
self.assertContains(response, "暂未发布")
self.assertNotContains(response, "/login?next=")
def test_homepage_shows_current_download_release(self):
release = self.create_download_release(
version="1.2.3",
external_url="https://download.example.com/cmhub-1.2.3.exe",
sha256="b" * 64,
release_notes="修复下载入口并补充 SHA256",
)
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["current_release"], release)
self.assertContains(response, "Windows 客户端 1.2.3")
self.assertContains(response, "https://download.example.com/cmhub-1.2.3.exe")
self.assertContains(response, "b" * 64)
self.assertContains(response, "修复下载入口并补充 SHA256")
self.assertContains(response, "未知发布者")
def test_homepage_shows_dashboard_entry_for_authenticated_user(self):
user = self.create_verified_user()
self.client.force_login(user)
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "进入控制台")
self.assertNotContains(response, "免费注册")
def test_download_release_only_keeps_one_current_per_platform(self):
old_release = self.create_download_release(version="1.0.0", sha256="c" * 64)
new_release = self.create_download_release(version="1.1.0", sha256="d" * 64)
old_release.refresh_from_db()
new_release.refresh_from_db()
self.assertFalse(old_release.is_current)
self.assertTrue(new_release.is_current)
def test_apikeys_requires_session_login(self):
response = self.client.get("/apikeys")
+2 -2
View File
@@ -1,11 +1,11 @@
from allauth.account.views import LoginView, LogoutView, SignupView
from django.urls import path
from django.views.generic import RedirectView
from .views import (
ApiKeyDeleteView,
ApiKeyListCreateView,
DashboardView,
HomeView,
ModelCatalogView,
RechargePageView,
RechargeRecordListView,
@@ -13,7 +13,7 @@ from .views import (
)
urlpatterns = [
path("", RedirectView.as_view(pattern_name="portal-dashboard", permanent=False), name="portal-home"),
path("", HomeView.as_view(), name="portal-home"),
path("signup", SignupView.as_view(), name="portal-signup"),
path("login", LoginView.as_view(), name="portal-login"),
path("logout", LogoutView.as_view(), name="portal-logout"),
+17
View File
@@ -19,6 +19,7 @@ from apps.billing.services import (
from apps.users.models import ApiKey
from .forms import ApiKeyCreateForm, RechargeCreateForm
from .models import DownloadRelease
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
@@ -88,6 +89,22 @@ def paginate_records(request, queryset):
}
class HomeView(TemplateView):
template_name = "portal/home.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["current_release"] = (
DownloadRelease.objects.filter(
platform=DownloadRelease.Platform.WINDOWS,
is_current=True,
)
.order_by("-created_at", "-id")
.first()
)
return context
class DashboardView(LoginRequiredMixin, TemplateView):
template_name = "portal/dashboard.html"