feat: add public homepage and downloads
This commit is contained in:
+62
-1
@@ -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]}..."
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user