Compare commits

...

3 Commits

Author SHA1 Message Date
hailin 41b8a8fcfb feat(auth): SMS 模板按类型分发 + 阿里云 8 模板配置
重构 auth-service 短信发送逻辑,从单一模板改为按验证类型分发不同模板。

变更:
- SmsVerificationType 枚举新增 3 类型: IDENTITY_VERIFY, TRANSACTION_CONFIRM, PAYMENT_VERIFY
- AliyunSmsProvider.getTemplateCode() 通过 TEMPLATE_ENV_MAP 按类型查找环境变量
  优先使用类型专属模板,fallback 到通用 ALIYUN_SMS_TEMPLATE_CODE
- 无模板配置时返回错误而非发送空模板
- 日志增加类型和模板代码,便于排查

阿里云已创建 8 个 Genex 短信模板:
- SMS_501745796 注册验证码 (已通过)
- SMS_501720822 登录验证码
- SMS_501735781 重置密码
- SMS_501925825 身份验证 (已通过)
- SMS_501820752 交易确认 (已通过)
- SMS_501855782 支付验证 (已通过)
- SMS_501780799 异常登录提醒 (已通过)
- SMS_501810819 账户变更通知

环境变量:
- .env.example / docker-compose.yml 新增 ALIYUN_SMS_TPL_* 共 7 项
- aliyun_sms_manager.py 迁移到 CreateSmsTemplate 新 API (旧 AddSmsTemplate 已下线)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:13:16 -08:00
hailin bd6ecaa0fd feat(portal): Genex 门户官网 — 20 页 Next.js SSG 站点
新增 frontend/portal/ 独立 Next.js 15 应用,作为 gogenex.com 品牌官网。

页面清单 (20 pages):
- 首页 (/) — Hero + 数据看板 + 三大优势 + 产品展示 + 信任背书 + CTA
- 关于我们 (/about) — 愿景 + 团队 + 里程碑时间线 (2025 Q1 起)
- 产品功能 (/features) — 购券流程 + 交易 + 清算 + 多端支持
- 解决方案 (/solutions) — 消费者/商户场景 + 用例故事
- 开发者 (/developers) — API 文档入口 + SDK + 区块链浏览器
- 联系我们 (/contact) — 联系表单 + 地址 + 社交
- 下载 (/download) — iOS/Android/Web/小程序 四端下载
- 职位招聘 (/careers) — 6 个岗位卡片
- 博客 (/blog) — 6 篇文章摘要
- 新闻稿 (/press) — 3 篇新闻发布
- 帮助中心 (/help) — 6 大帮助主题
- 社区 (/community) — 6 个社交渠道卡片
- 系统状态 (/status) — 6 项服务运行状态
- 服务条款 (/terms) — 完整法律条款
- 隐私政策 (/privacy) — 完整隐私声明
- Cookie 政策 (/cookie-policy)
- 风险披露 (/risk)

技术栈:
- Next.js 15 + React 18 + TypeScript + CSS Modules
- Framer Motion 滚动动画
- 复用 admin-web design-tokens.css 变量体系
- 完整 i18n (zh-CN / en-US, 800+ 翻译键)
- Docker 多阶段构建 + Nginx 反向代理配置
- SSG 静态生成,SEO 友好

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:03:53 -08:00
hailin 8af65a3a48 feat(scripts): 阿里云管理工具 (SMS/CloudAuth/Domain)
- aliyun_sms_manager.py: 签名/模板管理、发送记录查询、发送统计、发送测试
- aliyun_cloudauth_manager.py: 实人认证场景、人脸活体检测、身份二要素核验、套餐余额查询
- aliyun_domain_manager.py: 域名列表/详情/续费/转出/信息模板管理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:56:18 -08:00
57 changed files with 5928 additions and 9 deletions

View File

@ -62,6 +62,13 @@ ALIYUN_ACCESS_KEY_ID=
ALIYUN_ACCESS_KEY_SECRET= ALIYUN_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=券金融 ALIYUN_SMS_SIGN_NAME=券金融
ALIYUN_SMS_TEMPLATE_CODE= ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_TPL_REGISTER=SMS_501745796
ALIYUN_SMS_TPL_LOGIN=SMS_501720822
ALIYUN_SMS_TPL_RESET_PASSWORD=SMS_501735781
ALIYUN_SMS_TPL_CHANGE_PHONE=SMS_501925825
ALIYUN_SMS_TPL_IDENTITY_VERIFY=SMS_501925825
ALIYUN_SMS_TPL_TRANSACTION=SMS_501820752
ALIYUN_SMS_TPL_PAYMENT=SMS_501855782
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
# --- Account Lockout --- # --- Account Lockout ---

View File

@ -516,6 +516,13 @@ services:
- ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET:-} - ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET:-}
- ALIYUN_SMS_SIGN_NAME=${ALIYUN_SMS_SIGN_NAME:-} - ALIYUN_SMS_SIGN_NAME=${ALIYUN_SMS_SIGN_NAME:-}
- ALIYUN_SMS_TEMPLATE_CODE=${ALIYUN_SMS_TEMPLATE_CODE:-} - ALIYUN_SMS_TEMPLATE_CODE=${ALIYUN_SMS_TEMPLATE_CODE:-}
- ALIYUN_SMS_TPL_REGISTER=${ALIYUN_SMS_TPL_REGISTER:-SMS_501745796}
- ALIYUN_SMS_TPL_LOGIN=${ALIYUN_SMS_TPL_LOGIN:-SMS_501720822}
- ALIYUN_SMS_TPL_RESET_PASSWORD=${ALIYUN_SMS_TPL_RESET_PASSWORD:-SMS_501735781}
- ALIYUN_SMS_TPL_CHANGE_PHONE=${ALIYUN_SMS_TPL_CHANGE_PHONE:-SMS_501925825}
- ALIYUN_SMS_TPL_IDENTITY_VERIFY=${ALIYUN_SMS_TPL_IDENTITY_VERIFY:-SMS_501925825}
- ALIYUN_SMS_TPL_TRANSACTION=${ALIYUN_SMS_TPL_TRANSACTION:-SMS_501820752}
- ALIYUN_SMS_TPL_PAYMENT=${ALIYUN_SMS_TPL_PAYMENT:-SMS_501855782}
- ALIYUN_SMS_ENDPOINT=${ALIYUN_SMS_ENDPOINT:-dysmsapi.aliyuncs.com} - ALIYUN_SMS_ENDPOINT=${ALIYUN_SMS_ENDPOINT:-dysmsapi.aliyuncs.com}
depends_on: depends_on:
postgres: postgres:

View File

@ -25,7 +25,14 @@ SMS_MAX_VERIFY_ATTEMPTS=5
# ALIYUN_ACCESS_KEY_ID= # ALIYUN_ACCESS_KEY_ID=
# ALIYUN_ACCESS_KEY_SECRET= # ALIYUN_ACCESS_KEY_SECRET=
# ALIYUN_SMS_SIGN_NAME=券金融 # ALIYUN_SMS_SIGN_NAME=券金融
# ALIYUN_SMS_TEMPLATE_CODE=SMS_123456789 # ALIYUN_SMS_TEMPLATE_CODE=SMS_501745796
# ALIYUN_SMS_TPL_REGISTER=SMS_501745796
# ALIYUN_SMS_TPL_LOGIN=SMS_501720822
# ALIYUN_SMS_TPL_RESET_PASSWORD=SMS_501735781
# ALIYUN_SMS_TPL_CHANGE_PHONE=SMS_501925825
# ALIYUN_SMS_TPL_IDENTITY_VERIFY=SMS_501925825
# ALIYUN_SMS_TPL_TRANSACTION=SMS_501820752
# ALIYUN_SMS_TPL_PAYMENT=SMS_501855782
# ── Kafka (optional, events silently skipped if unavailable) ── # ── Kafka (optional, events silently skipped if unavailable) ──
KAFKA_BROKERS=localhost:9092 KAFKA_BROKERS=localhost:9092

View File

@ -11,6 +11,9 @@ export enum SmsVerificationType {
LOGIN = 'LOGIN', LOGIN = 'LOGIN',
RESET_PASSWORD = 'RESET_PASSWORD', RESET_PASSWORD = 'RESET_PASSWORD',
CHANGE_PHONE = 'CHANGE_PHONE', CHANGE_PHONE = 'CHANGE_PHONE',
IDENTITY_VERIFY = 'IDENTITY_VERIFY',
TRANSACTION_CONFIRM = 'TRANSACTION_CONFIRM',
PAYMENT_VERIFY = 'PAYMENT_VERIFY',
} }
@Entity('sms_verifications') @Entity('sms_verifications')

View File

@ -5,14 +5,17 @@ import { ISmsProvider, SmsDeliveryResult } from './sms-provider.interface';
/** /**
* SMS Provider * SMS Provider
* *
* rwdurian identity-service
* 使 sendSmsWithOptions + RuntimeOptions API
*
* : * :
* - ALIYUN_ACCESS_KEY_ID * - ALIYUN_ACCESS_KEY_ID / ALIYUN_ACCESS_KEY_SECRET
* - ALIYUN_ACCESS_KEY_SECRET
* - ALIYUN_SMS_SIGN_NAME () * - ALIYUN_SMS_SIGN_NAME ()
* - ALIYUN_SMS_TEMPLATE_CODE () * - ALIYUN_SMS_TEMPLATE_CODE ( fallback )
* - ALIYUN_SMS_TPL_REGISTER
* - ALIYUN_SMS_TPL_LOGIN
* - ALIYUN_SMS_TPL_RESET_PASSWORD
* - ALIYUN_SMS_TPL_CHANGE_PHONE
* - ALIYUN_SMS_TPL_IDENTITY_VERIFY
* - ALIYUN_SMS_TPL_TRANSACTION
* - ALIYUN_SMS_TPL_PAYMENT
* - ALIYUN_SMS_ENDPOINT ( dysmsapi.aliyuncs.com) * - ALIYUN_SMS_ENDPOINT ( dysmsapi.aliyuncs.com)
*/ */
@Injectable() @Injectable()
@ -20,6 +23,17 @@ export class AliyunSmsProvider implements ISmsProvider {
private readonly logger = new Logger('AliyunSmsProvider'); private readonly logger = new Logger('AliyunSmsProvider');
private client: any; // Dysmsapi20170525 client (lazy init) private client: any; // Dysmsapi20170525 client (lazy init)
/** 类型 → 环境变量名 映射 */
private static readonly TEMPLATE_ENV_MAP: Record<SmsVerificationType, string> = {
[SmsVerificationType.REGISTER]: 'ALIYUN_SMS_TPL_REGISTER',
[SmsVerificationType.LOGIN]: 'ALIYUN_SMS_TPL_LOGIN',
[SmsVerificationType.RESET_PASSWORD]: 'ALIYUN_SMS_TPL_RESET_PASSWORD',
[SmsVerificationType.CHANGE_PHONE]: 'ALIYUN_SMS_TPL_CHANGE_PHONE',
[SmsVerificationType.IDENTITY_VERIFY]: 'ALIYUN_SMS_TPL_IDENTITY_VERIFY',
[SmsVerificationType.TRANSACTION_CONFIRM]: 'ALIYUN_SMS_TPL_TRANSACTION',
[SmsVerificationType.PAYMENT_VERIFY]: 'ALIYUN_SMS_TPL_PAYMENT',
};
async send( async send(
phone: string, phone: string,
code: string, code: string,
@ -35,6 +49,11 @@ export class AliyunSmsProvider implements ISmsProvider {
const signName = process.env.ALIYUN_SMS_SIGN_NAME || '券金融'; const signName = process.env.ALIYUN_SMS_SIGN_NAME || '券金融';
const templateCode = this.getTemplateCode(type); const templateCode = this.getTemplateCode(type);
if (!templateCode) {
this.logger.error(`No SMS template configured for type: ${type}`);
return { success: false, errorMsg: `No template for type: ${type}` };
}
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Dysmsapi = require('@alicloud/dysmsapi20170525'); const Dysmsapi = require('@alicloud/dysmsapi20170525');
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
@ -56,7 +75,7 @@ export class AliyunSmsProvider implements ISmsProvider {
if (response.body?.code === 'OK') { if (response.body?.code === 'OK') {
this.logger.log( this.logger.log(
`SMS sent: phone=${phone.slice(0, 3)}****${phone.slice(-4)} bizId=${response.body.bizId}`, `SMS sent [${type}]: phone=${phone.slice(0, 3)}****${phone.slice(-4)} tpl=${templateCode} bizId=${response.body.bizId}`,
); );
return { return {
success: true, success: true,
@ -78,7 +97,9 @@ export class AliyunSmsProvider implements ISmsProvider {
} }
private getTemplateCode(type: SmsVerificationType): string { private getTemplateCode(type: SmsVerificationType): string {
return process.env.ALIYUN_SMS_TEMPLATE_CODE || ''; const envKey = AliyunSmsProvider.TEMPLATE_ENV_MAP[type];
const specific = envKey ? process.env[envKey] : undefined;
return specific || process.env.ALIYUN_SMS_TEMPLATE_CODE || '';
} }
private async getClient() { private async getClient() {

4
frontend/portal/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.next/
out/
.env*.local

View File

@ -0,0 +1,47 @@
# 阶段1: 依赖安装
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json ./
COPY package-lock.json* ./
RUN npm config set registry https://registry.npmmirror.com && \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
# 阶段2: 构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# 阶段3: 生产运行
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

249
frontend/portal/deploy.sh Normal file
View File

@ -0,0 +1,249 @@
#!/bin/bash
# =============================================================================
# Genex Portal - 部署管理脚本
# =============================================================================
#
# Docker 命令:
# ./deploy.sh build 仅构建 Docker 镜像
# ./deploy.sh start 构建并启动服务 (默认)
# ./deploy.sh stop 停止服务
# ./deploy.sh restart 重启服务
# ./deploy.sh logs 查看服务日志
# ./deploy.sh status 查看服务状态
# ./deploy.sh clean 清理容器和镜像
#
# Nginx + SSL 命令 (需要 root 权限):
# sudo ./deploy.sh nginx install [domain] [email] 安装 Nginx + SSL 证书
# sudo ./deploy.sh nginx ssl [domain] [email] 仅申请/续期 SSL 证书
# ./deploy.sh nginx status 查看 Nginx 状态
# ./deploy.sh nginx reload 重载 Nginx 配置
#
# 默认域名: gogenex.com
# 默认邮箱: admin@gogenex.com
#
# =============================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
PROJECT_NAME="genex-portal"
IMAGE_NAME="genex-portal"
CONTAINER_NAME="genex-portal"
DEFAULT_PORT=3001
DEFAULT_DOMAIN="gogenex.com"
DEFAULT_EMAIL="admin@gogenex.com"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
check_docker() {
if ! command -v docker &> /dev/null; then
log_error "Docker 未安装"
exit 1
fi
if ! docker info &> /dev/null; then
log_error "Docker 服务未运行"
exit 1
fi
}
check_docker_compose() {
if docker compose version &> /dev/null; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
COMPOSE_CMD="docker-compose"
else
log_error "Docker Compose 未安装"
exit 1
fi
}
build() {
log_info "构建 Docker 镜像..."
$COMPOSE_CMD build --no-cache
log_success "镜像构建完成"
}
start() {
log_info "部署 Genex Portal..."
PORT=${PORT:-$DEFAULT_PORT}
if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null 2>&1; then
log_warn "端口 $PORT 已被占用,停止旧服务..."
stop
fi
$COMPOSE_CMD up -d --build
log_info "等待服务启动..."
sleep 5
if docker ps | grep -q $CONTAINER_NAME; then
log_success "服务部署成功! http://localhost:$PORT"
else
log_error "服务启动失败,查看日志: ./deploy.sh logs"
exit 1
fi
}
stop() {
log_info "停止服务..."
$COMPOSE_CMD down
log_success "服务已停止"
}
restart() { stop; start; }
logs() { $COMPOSE_CMD logs -f; }
clean() {
log_info "清理容器和镜像..."
$COMPOSE_CMD down --rmi local --volumes --remove-orphans
docker image prune -f
log_success "清理完成"
}
status() {
log_info "服务状态:"
docker ps -a --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
}
cmd_nginx_install() {
local domain="${1:-$DEFAULT_DOMAIN}"
local email="${2:-$DEFAULT_EMAIL}"
local conf_file="$SCRIPT_DIR/nginx/${domain}.conf"
log_info "$domain 安装 Nginx + SSL..."
if [ ! -f "$conf_file" ]; then
log_error "配置文件不存在: $conf_file"
exit 1
fi
if [ "$EUID" -ne 0 ]; then
log_error "需要 root 权限: sudo ./deploy.sh nginx install $domain $email"
exit 1
fi
# 安装依赖
log_info "[1/5] 检查依赖..."
command -v nginx &> /dev/null || { apt update && apt install -y nginx; systemctl enable nginx; systemctl start nginx; }
command -v certbot &> /dev/null || apt install -y certbot python3-certbot-nginx
# HTTP 临时配置
log_info "[2/5] 部署 HTTP 临时配置..."
mkdir -p /var/www/certbot
cat > /etc/nginx/sites-available/$domain << HTTPEOF
server {
listen 80;
listen [::]:80;
server_name $domain www.$domain;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / {
proxy_pass http://127.0.0.1:${DEFAULT_PORT};
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
HTTPEOF
ln -sf /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
# SSL 证书
log_info "[3/5] 申请 SSL 证书..."
if [ -d "/etc/letsencrypt/live/$domain" ]; then
log_warn "证书已存在,跳过"
else
certbot certonly --webroot --webroot-path=/var/www/certbot \
--email $email --agree-tos --no-eff-email \
-d $domain -d www.$domain
log_success "SSL 证书申请成功!"
fi
# HTTPS 完整配置
log_info "[4/5] 部署 HTTPS 配置..."
cp "$conf_file" /etc/nginx/sites-available/$domain
nginx -t && systemctl reload nginx
# 自动续期
log_info "[5/5] 配置自动续期..."
if [ ! -f /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ]; then
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'HOOKEOF'
#!/bin/bash
systemctl reload nginx
HOOKEOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
fi
log_success "完成! https://$domain"
}
cmd_nginx_ssl() {
local domain="${1:-$DEFAULT_DOMAIN}"
local email="${2:-$DEFAULT_EMAIL}"
[ "$EUID" -ne 0 ] && { log_error "需要 root 权限"; exit 1; }
mkdir -p /var/www/certbot
if [ -d "/etc/letsencrypt/live/$domain" ]; then
certbot renew --cert-name $domain
else
certbot certonly --webroot --webroot-path=/var/www/certbot \
--email $email --agree-tos --no-eff-email \
-d $domain -d www.$domain
fi
log_success "SSL 证书完成"
}
cmd_nginx_status() {
systemctl is-active nginx &>/dev/null && echo -e "Nginx: ${GREEN}运行中${NC}" || echo -e "Nginx: ${RED}未运行${NC}"
command -v certbot &>/dev/null && certbot certificates 2>/dev/null | grep -E "(Certificate Name|Expiry Date|Domains)" | sed 's/^/ /'
}
cmd_nginx_reload() {
[ "$EUID" -ne 0 ] && { log_error "需要 root 权限"; exit 1; }
nginx -t && systemctl reload nginx
log_success "Nginx 已重载"
}
show_help() {
echo ""
echo "Genex Portal 部署脚本"
echo ""
echo "Docker: build | start | stop | restart | logs | status | clean"
echo "Nginx: nginx install [domain] [email] | nginx ssl | nginx status | nginx reload"
echo ""
echo "默认域名: $DEFAULT_DOMAIN 端口: $DEFAULT_PORT"
echo ""
}
main() {
cd "$SCRIPT_DIR"
case "${1:-start}" in
build) check_docker; check_docker_compose; build ;;
start) check_docker; check_docker_compose; start ;;
stop) check_docker; check_docker_compose; stop ;;
restart) check_docker; check_docker_compose; restart ;;
logs) check_docker; check_docker_compose; logs ;;
status) check_docker; status ;;
clean) check_docker; check_docker_compose; clean ;;
nginx)
case "${2:-install}" in
install) cmd_nginx_install "$3" "$4" ;;
ssl) cmd_nginx_ssl "$3" "$4" ;;
status) cmd_nginx_status ;;
reload) cmd_nginx_reload ;;
*) log_error "未知命令: $2"; exit 1 ;;
esac
;;
help|--help|-h) show_help ;;
*) log_error "未知命令: $1"; show_help; exit 1 ;;
esac
}
main "$@"

View File

@ -0,0 +1,26 @@
services:
portal:
build:
context: .
dockerfile: Dockerfile
image: genex-portal:latest
container_name: genex-portal
restart: unless-stopped
ports:
- "${PORT:-4080}:3000"
environment:
- TZ=Asia/Shanghai
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 3s
retries: 3
start_period: 40s
networks:
- genex-network
networks:
genex-network:
driver: bridge

5
frontend/portal/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

View File

@ -0,0 +1,95 @@
# =============================================================================
# Genex Portal - gogenex.com / www.gogenex.com
# 官网门户 (Next.js SSG)
# 部署端口: 3001 (Docker 容器内部 3000)
# =============================================================================
# HTTP → HTTPS 重定向
server {
listen 80;
listen [::]:80;
server_name gogenex.com www.gogenex.com;
# Let's Encrypt ACME 验证
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# www → 裸域重定向
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.gogenex.com;
ssl_certificate /etc/letsencrypt/live/gogenex.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gogenex.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://gogenex.com$request_uri;
}
# 主站 HTTPS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name gogenex.com;
# ---- SSL ----
ssl_certificate /etc/letsencrypt/live/gogenex.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gogenex.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# ---- 安全头 ----
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ---- Gzip ----
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
# ---- 日志 ----
access_log /var/log/nginx/gogenex.com.access.log;
error_log /var/log/nginx/gogenex.com.error.log;
# ---- Next.js 静态资源 (长期缓存) ----
location /_next/static/ {
proxy_pass http://127.0.0.1:4080;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# ---- 反向代理到 Docker ----
location / {
proxy_pass http://127.0.0.1:4080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# ---- 健康检查 ----
location = /nginx-health {
access_log off;
return 200 '{"status":"ok","service":"genex-portal-nginx"}';
add_header Content-Type application/json;
}
}

1021
frontend/portal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "genex-portal",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"next": "15.1.11",
"framer-motion": "^11.0.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/node": "^20.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

View File

@ -0,0 +1,30 @@
<svg viewBox="0 0 200 72" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="L" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6C5CE7"/>
<stop offset="100%" stop-color="#7B6DEE"/>
</linearGradient>
<linearGradient id="R" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#9B8FFF"/>
<stop offset="100%" stop-color="#B8ADFF"/>
</linearGradient>
<linearGradient id="EX" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#9B8FFF"/>
<stop offset="100%" stop-color="#B8ADFF"/>
</linearGradient>
<mask id="notch">
<rect x="-36" y="-36" width="72" height="72" fill="white"/>
<circle cx="0" cy="-28" r="4" fill="black"/>
<circle cx="0" cy="28" r="4" fill="black"/>
</mask>
</defs>
<g transform="translate(36,36)">
<g transform="rotate(45) scale(0.7)" mask="url(#notch)">
<path d="M-28 -28 L-0.05 -28 L-6 -2 L0 2 L-0.05 28 L-28 28 Z" fill="url(#L)"/>
<path d="M0.05 -28 L28 -28 L28 28 L0.05 28 L6 2 L0 -2 Z" fill="url(#R)"/>
</g>
</g>
<text x="78" y="41" font-family="Sora, sans-serif" font-weight="800" font-size="20" letter-spacing="-0.3">
<tspan fill="#1A103A">GEN</tspan><tspan fill="url(#EX)">EX</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,23 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="L" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6C5CE7"/>
<stop offset="100%" stop-color="#7B6DEE"/>
</linearGradient>
<linearGradient id="R" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#9B8FFF"/>
<stop offset="100%" stop-color="#B8ADFF"/>
</linearGradient>
<mask id="notch">
<rect x="-36" y="-36" width="72" height="72" fill="white"/>
<circle cx="0" cy="-28" r="4" fill="black"/>
<circle cx="0" cy="28" r="4" fill="black"/>
</mask>
</defs>
<g transform="translate(50,50)">
<g transform="rotate(45)" mask="url(#notch)">
<path d="M-28 -28 L-0.05 -28 L-6 -2 L0 2 L-0.05 28 L-28 28 Z" fill="url(#L)"/>
<path d="M0.05 -28 L28 -28 L28 28 L0.05 28 L6 2 L0 -2 Z" fill="url(#R)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 920 B

View File

@ -0,0 +1,159 @@
/* ---- About Hero ---- */
.hero {
padding: 160px 24px 80px;
text-align: center;
background: linear-gradient(180deg, var(--color-primary-surface) 0%, var(--color-surface) 100%);
}
.title {
font-size: 48px;
font-weight: 700;
margin-bottom: 16px;
}
.subtitle {
font-size: 20px;
color: var(--color-text-secondary);
}
/* ---- Vision / Mission ---- */
.visionSection {
padding: 80px 24px;
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
}
.visionCard {
background: var(--color-gray-50);
border-radius: var(--radius-lg);
padding: 48px 40px;
}
.visionTitle {
font-size: 24px;
font-weight: 600;
margin-bottom: 16px;
color: var(--color-primary);
}
.visionDesc {
font-size: 16px;
color: var(--color-text-secondary);
line-height: 1.8;
}
/* ---- Values ---- */
.values {
padding: 80px 24px;
background: var(--color-gray-50);
}
.valuesTitle {
font-size: 36px;
font-weight: 700;
text-align: center;
margin-bottom: 48px;
}
.valuesGrid {
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.valueCard {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 32px 24px;
text-align: center;
border: 1px solid var(--color-border-light);
}
.valueIcon {
font-size: 40px;
margin-bottom: 16px;
}
.valueName {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.valueDesc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.6;
}
/* ---- Timeline ---- */
.timeline {
padding: 80px 24px;
}
.timelineTitle {
font-size: 36px;
font-weight: 700;
text-align: center;
margin-bottom: 64px;
}
.timelineTrack {
max-width: 700px;
margin: 0 auto;
position: relative;
padding-left: 40px;
}
.timelineTrack::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 2px;
background: var(--color-primary-container);
}
.timelineItem {
position: relative;
padding-bottom: 40px;
}
.timelineItem:last-child {
padding-bottom: 0;
}
.timelineDot {
position: absolute;
left: -33px;
top: 4px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-primary);
border: 3px solid var(--color-primary-surface);
}
.timelineDate {
font-size: 14px;
font-weight: 600;
color: var(--color-primary);
margin-bottom: 4px;
}
.timelineText {
font-size: 16px;
color: var(--color-text-secondary);
}
@media (max-width: 768px) {
.title { font-size: 32px; }
.visionSection { grid-template-columns: 1fr; }
.valuesGrid { grid-template-columns: 1fr 1fr; }
}

View File

@ -0,0 +1,82 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from './page.module.css';
export default function AboutPage() {
const t = useT();
const values = [
{ icon: '\u{1F465}', nameKey: 'about_value_1', descKey: 'about_value_1_desc' },
{ icon: '\u{1F50D}', nameKey: 'about_value_2', descKey: 'about_value_2_desc' },
{ icon: '\u{1F680}', nameKey: 'about_value_3', descKey: 'about_value_3_desc' },
{ icon: '\u{1F3DB}', nameKey: 'about_value_4', descKey: 'about_value_4_desc' },
];
const milestones = [
{ date: '2025 Q1', key: 'about_milestone_2025_q1' },
{ date: '2025 Q2', key: 'about_milestone_2025_q2' },
{ date: '2025 Q3', key: 'about_milestone_2025_q3' },
{ date: '2025 Q4', key: 'about_milestone_2025_q4' },
{ date: '2026 Q1', key: 'about_milestone_2026_q1' },
{ date: '2026 Q2', key: 'about_milestone_2026_q2' },
];
return (
<>
<section className={s.hero}>
<AnimateOnScroll direction="none">
<h1 className={s.title}>{t('about_title')}</h1>
<p className={s.subtitle}>{t('about_subtitle')}</p>
</AnimateOnScroll>
</section>
<div className={s.visionSection}>
<AnimateOnScroll direction="left">
<div className={s.visionCard}>
<h2 className={s.visionTitle}>{t('about_vision_title')}</h2>
<p className={s.visionDesc}>{t('about_vision_desc')}</p>
</div>
</AnimateOnScroll>
<AnimateOnScroll direction="right">
<div className={s.visionCard}>
<h2 className={s.visionTitle}>{t('about_mission_title')}</h2>
<p className={s.visionDesc}>{t('about_mission_desc')}</p>
</div>
</AnimateOnScroll>
</div>
<section className={s.values}>
<h2 className={s.valuesTitle}>{t('about_value_title')}</h2>
<div className={s.valuesGrid}>
{values.map((v, i) => (
<AnimateOnScroll key={v.nameKey} delay={i * 0.1}>
<div className={s.valueCard}>
<div className={s.valueIcon}>{v.icon}</div>
<h3 className={s.valueName}>{t(v.nameKey)}</h3>
<p className={s.valueDesc}>{t(v.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</section>
<section className={s.timeline}>
<h2 className={s.timelineTitle}>{t('about_milestone_title')}</h2>
<div className={s.timelineTrack}>
{milestones.map((m, i) => (
<AnimateOnScroll key={m.key} delay={i * 0.1} direction="left">
<div className={s.timelineItem}>
<div className={s.timelineDot} />
<div className={s.timelineDate}>{m.date}</div>
<div className={s.timelineText}>{t(m.key)}</div>
</div>
</AnimateOnScroll>
))}
</div>
</section>
</>
);
}

View File

@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function BlogPage() {
const t = useT();
const posts = [
{ icon: '\u{1F680}', dateKey: 'blog_p1_date', titleKey: 'blog_p1_title', excerptKey: 'blog_p1_excerpt' },
{ icon: '\u{26D3}', dateKey: 'blog_p2_date', titleKey: 'blog_p2_title', excerptKey: 'blog_p2_excerpt' },
{ icon: '\u{1F4B0}', dateKey: 'blog_p3_date', titleKey: 'blog_p3_title', excerptKey: 'blog_p3_excerpt' },
{ icon: '\u{2B50}', dateKey: 'blog_p4_date', titleKey: 'blog_p4_title', excerptKey: 'blog_p4_excerpt' },
{ icon: '\u{1F916}', dateKey: 'blog_p5_date', titleKey: 'blog_p5_title', excerptKey: 'blog_p5_excerpt' },
{ icon: '\u{1F30F}', dateKey: 'blog_p6_date', titleKey: 'blog_p6_title', excerptKey: 'blog_p6_excerpt' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('blog_title')}</h1>
<p className={s.subtitle}>{t('blog_subtitle')}</p>
</section>
<div className={s.grid}>
{posts.map((p, i) => (
<AnimateOnScroll key={p.titleKey} delay={i * 0.1}>
<div className={s.blogCard}>
<div className={s.blogThumb}>{p.icon}</div>
<div className={s.blogBody}>
<div className={s.blogDate}>{t(p.dateKey)}</div>
<h3 className={s.blogTitle}>{t(p.titleKey)}</h3>
<p className={s.blogExcerpt}>{t(p.excerptKey)}</p>
</div>
</div>
</AnimateOnScroll>
))}
</div>
</>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function CareersPage() {
const t = useT();
const jobs = [
{ icon: '\u{1F4BB}', titleKey: 'careers_job1_title', descKey: 'careers_job1_desc' },
{ icon: '\u{26D3}', titleKey: 'careers_job2_title', descKey: 'careers_job2_desc' },
{ icon: '\u{1F4F1}', titleKey: 'careers_job3_title', descKey: 'careers_job3_desc' },
{ icon: '\u{1F6E1}', titleKey: 'careers_job4_title', descKey: 'careers_job4_desc' },
{ icon: '\u{1F4CA}', titleKey: 'careers_job5_title', descKey: 'careers_job5_desc' },
{ icon: '\u{1F3A8}', titleKey: 'careers_job6_title', descKey: 'careers_job6_desc' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('careers_title')}</h1>
<p className={s.subtitle}>{t('careers_subtitle')}</p>
</section>
<div className={s.grid}>
{jobs.map((j, i) => (
<AnimateOnScroll key={j.titleKey} delay={i * 0.1}>
<div className={s.card}>
<div className={s.cardIcon}>{j.icon}</div>
<h3 className={s.cardTitle}>{t(j.titleKey)}</h3>
<p className={s.cardDesc}>{t(j.descKey)}</p>
<a href="mailto:hr@gogenex.com" className={s.cardLink}>{t('careers_apply')} &rarr;</a>
</div>
</AnimateOnScroll>
))}
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function CommunityPage() {
const t = useT();
const channels = [
{ icon: '\u{1F4AC}', titleKey: 'community_c1_title', descKey: 'community_c1_desc', link: '#' },
{ icon: '\u{1F426}', titleKey: 'community_c2_title', descKey: 'community_c2_desc', link: '#' },
{ icon: '\u{1F4E2}', titleKey: 'community_c3_title', descKey: 'community_c3_desc', link: '#' },
{ icon: '\u{1F310}', titleKey: 'community_c4_title', descKey: 'community_c4_desc', link: '#' },
{ icon: '\u{1F4F1}', titleKey: 'community_c5_title', descKey: 'community_c5_desc', link: '#' },
{ icon: '\u{1F3AE}', titleKey: 'community_c6_title', descKey: 'community_c6_desc', link: '#' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('community_title')}</h1>
<p className={s.subtitle}>{t('community_subtitle')}</p>
</section>
<div className={s.grid}>
{channels.map((ch, i) => (
<AnimateOnScroll key={ch.titleKey} delay={i * 0.1}>
<div className={s.card}>
<div className={s.cardIcon}>{ch.icon}</div>
<h3 className={s.cardTitle}>{t(ch.titleKey)}</h3>
<p className={s.cardDesc}>{t(ch.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</>
);
}

View File

@ -0,0 +1,137 @@
.hero {
padding: 160px 24px 80px;
text-align: center;
background: linear-gradient(180deg, var(--color-primary-surface) 0%, var(--color-surface) 100%);
}
.title {
font-size: 48px;
font-weight: 700;
margin-bottom: 16px;
}
.subtitle {
font-size: 20px;
color: var(--color-text-secondary);
}
.content {
max-width: var(--max-width);
margin: 0 auto;
padding: 80px 24px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 64px;
}
/* ---- Form ---- */
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.label {
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 6px;
display: block;
}
.input {
width: 100%;
padding: 12px 16px;
font-size: 15px;
font-family: var(--font-family);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s;
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-surface);
}
.textarea {
width: 100%;
padding: 12px 16px;
font-size: 15px;
font-family: var(--font-family);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
min-height: 140px;
resize: vertical;
transition: border-color 0.2s;
}
.textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-surface);
}
.submitBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 14px 32px;
font-size: 16px;
font-weight: 600;
transition: all 0.2s;
align-self: flex-start;
}
.submitBtn:hover {
background: var(--color-primary-dark);
box-shadow: var(--shadow-primary);
}
.successMsg {
font-size: 15px;
color: var(--color-success);
font-weight: 500;
}
/* ---- Info ---- */
.info {
display: flex;
flex-direction: column;
gap: 40px;
}
.infoCard {
display: flex;
gap: 16px;
}
.infoIcon {
width: 48px;
height: 48px;
border-radius: 14px;
background: var(--color-primary-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.infoTitle {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.infoText {
font-size: 15px;
color: var(--color-text-secondary);
}
@media (max-width: 768px) {
.title { font-size: 32px; }
.content { grid-template-columns: 1fr; gap: 48px; }
}

View File

@ -0,0 +1,83 @@
'use client';
import React, { useState, FormEvent } from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from './page.module.css';
export default function ContactPage() {
const t = useT();
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setSubmitted(true);
};
return (
<>
<section className={s.hero}>
<AnimateOnScroll direction="none">
<h1 className={s.title}>{t('contact_title')}</h1>
<p className={s.subtitle}>{t('contact_subtitle')}</p>
</AnimateOnScroll>
</section>
<div className={s.content}>
<AnimateOnScroll direction="left">
{submitted ? (
<p className={s.successMsg}>{t('contact_form_success')}</p>
) : (
<form className={s.form} onSubmit={handleSubmit}>
<div>
<label className={s.label}>{t('contact_form_name')}</label>
<input className={s.input} type="text" required />
</div>
<div>
<label className={s.label}>{t('contact_form_email')}</label>
<input className={s.input} type="email" required />
</div>
<div>
<label className={s.label}>{t('contact_form_subject')}</label>
<input className={s.input} type="text" required />
</div>
<div>
<label className={s.label}>{t('contact_form_message')}</label>
<textarea className={s.textarea} required />
</div>
<button type="submit" className={s.submitBtn}>
{t('contact_form_submit')}
</button>
</form>
)}
</AnimateOnScroll>
<AnimateOnScroll direction="right">
<div className={s.info}>
<div className={s.infoCard}>
<div className={s.infoIcon}>{'\u{1F3E2}'}</div>
<div>
<div className={s.infoTitle}>{t('contact_office_title')}</div>
<div className={s.infoText}>{t('contact_office_addr')}</div>
</div>
</div>
<div className={s.infoCard}>
<div className={s.infoIcon}>{'\u{2709}'}</div>
<div>
<div className={s.infoTitle}>{t('contact_email_title')}</div>
<div className={s.infoText}>{t('contact_email_addr')}</div>
</div>
</div>
<div className={s.infoCard}>
<div className={s.infoIcon}>{'\u{1F310}'}</div>
<div>
<div className={s.infoTitle}>{t('contact_social_title')}</div>
<div className={s.infoText}>WeChat / Twitter / Telegram</div>
</div>
</div>
</div>
</AnimateOnScroll>
</div>
</>
);
}

View File

@ -0,0 +1,193 @@
/* Shared styles for content/legal pages */
.hero {
padding: 160px 24px 60px;
text-align: center;
background: linear-gradient(180deg, var(--color-primary-surface) 0%, var(--color-surface) 100%);
}
.title {
font-size: 42px;
font-weight: 700;
margin-bottom: 12px;
}
.subtitle {
font-size: 18px;
color: var(--color-text-secondary);
max-width: 600px;
margin: 0 auto;
}
.body {
max-width: 800px;
margin: 0 auto;
padding: 60px 24px 100px;
}
.body h2 {
font-size: 24px;
font-weight: 600;
margin: 40px 0 16px;
color: var(--color-text-primary);
}
.body h3 {
font-size: 18px;
font-weight: 600;
margin: 28px 0 12px;
color: var(--color-text-primary);
}
.body p {
font-size: 15px;
line-height: 1.8;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
.body ul {
padding-left: 24px;
margin-bottom: 16px;
}
.body li {
font-size: 15px;
line-height: 1.8;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
.updated {
font-size: 14px;
color: var(--color-text-tertiary);
margin-bottom: 40px;
}
/* ---- Grid layout for pages like careers, help, status ---- */
.grid {
max-width: var(--max-width);
margin: 0 auto;
padding: 80px 24px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
}
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 36px 28px;
border: 1px solid var(--color-border-light);
transition: all 0.3s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.cardIcon {
font-size: 36px;
margin-bottom: 16px;
}
.cardTitle {
font-size: 20px;
font-weight: 600;
margin-bottom: 10px;
}
.cardDesc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.7;
}
.cardLink {
display: inline-block;
margin-top: 16px;
font-size: 14px;
font-weight: 600;
color: var(--color-primary);
}
/* ---- Status indicator ---- */
.statusDot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-success);
margin-right: 8px;
}
.statusRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--color-border-light);
font-size: 15px;
}
.statusLabel {
display: flex;
align-items: center;
gap: 8px;
}
.statusBadge {
font-size: 13px;
font-weight: 500;
color: var(--color-success);
}
/* ---- Blog card ---- */
.blogCard {
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border-light);
transition: all 0.3s;
}
.blogCard:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.blogThumb {
height: 180px;
background: linear-gradient(135deg, var(--color-primary-surface), var(--color-primary-container));
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
}
.blogBody {
padding: 24px;
}
.blogDate {
font-size: 13px;
color: var(--color-text-tertiary);
margin-bottom: 8px;
}
.blogTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.blogExcerpt {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.6;
}
@media (max-width: 768px) {
.title { font-size: 28px; }
.grid { grid-template-columns: 1fr; max-width: 480px; }
}

View File

@ -0,0 +1,31 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import s from '../content.module.css';
export default function CookiePolicyPage() {
const t = useT();
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('cookie_title')}</h1>
</section>
<div className={s.body}>
<p className={s.updated}>{t('cookie_updated')}</p>
<h2>{t('cookie_s1_title')}</h2>
<p>{t('cookie_s1_p1')}</p>
<h2>{t('cookie_s2_title')}</h2>
<p>{t('cookie_s2_p1')}</p>
<h3>{t('cookie_s2_h3_1')}</h3>
<p>{t('cookie_s2_p2')}</p>
<h3>{t('cookie_s2_h3_2')}</h3>
<p>{t('cookie_s2_p3')}</p>
<h3>{t('cookie_s2_h3_3')}</h3>
<p>{t('cookie_s2_p4')}</p>
<h2>{t('cookie_s3_title')}</h2>
<p>{t('cookie_s3_p1')}</p>
</div>
</>
);
}

View File

@ -0,0 +1,122 @@
.hero {
padding: 160px 24px 80px;
text-align: center;
background: linear-gradient(135deg, var(--color-gray-900), #2d1b69);
color: #fff;
}
.title {
font-size: 48px;
font-weight: 700;
margin-bottom: 16px;
}
.subtitle {
font-size: 20px;
color: rgba(255, 255, 255, 0.6);
max-width: 600px;
margin: 0 auto;
}
.grid {
max-width: var(--max-width);
margin: 0 auto;
padding: 80px 24px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 28px;
}
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 40px 36px;
border: 1px solid var(--color-border-light);
transition: all 0.3s;
}
.card:hover {
border-color: var(--color-primary-container);
box-shadow: var(--shadow-lg);
}
.cardIcon {
font-size: 36px;
margin-bottom: 20px;
}
.cardTitle {
font-size: 22px;
font-weight: 600;
margin-bottom: 10px;
}
.cardDesc {
font-size: 15px;
color: var(--color-text-secondary);
line-height: 1.7;
margin-bottom: 20px;
}
.cardLink {
display: inline-block;
font-size: 14px;
font-weight: 600;
color: var(--color-primary);
transition: color 0.2s;
}
.cardLink:hover {
color: var(--color-primary-dark);
}
/* ---- Quick Start ---- */
.quickstart {
padding: 80px 24px;
background: var(--color-gray-50);
}
.qsTitle {
font-size: 32px;
font-weight: 700;
text-align: center;
margin-bottom: 40px;
}
.codeBlock {
max-width: 700px;
margin: 0 auto;
background: var(--color-gray-900);
border-radius: var(--radius-md);
padding: 32px;
overflow-x: auto;
}
.codeBlock pre {
font-family: var(--font-family-mono);
font-size: 14px;
color: #e0e0e0;
line-height: 1.7;
margin: 0;
}
.codeComment {
color: #6a9955;
}
.codeKeyword {
color: #c586c0;
}
.codeString {
color: #ce9178;
}
.codeFunc {
color: #dcdcaa;
}
@media (max-width: 768px) {
.title { font-size: 32px; }
.grid { grid-template-columns: 1fr; }
}

View File

@ -0,0 +1,50 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from './page.module.css';
export default function DevelopersPage() {
const t = useT();
const resources = [
{ icon: '\u{1F4E1}', titleKey: 'dev_api_title', descKey: 'dev_api_desc', link: '#', linkKey: 'dev_doc_link' },
{ icon: '\u{1F4E6}', titleKey: 'dev_sdk_title', descKey: 'dev_sdk_desc', link: '#', linkKey: 'dev_doc_link' },
{ icon: '\u{1F50D}', titleKey: 'dev_explorer_title', descKey: 'dev_explorer_desc', link: '#', linkKey: 'dev_explorer_link' },
{ icon: '\u{1F9EA}', titleKey: 'dev_sandbox_title', descKey: 'dev_sandbox_desc', link: '#', linkKey: 'dev_doc_link' },
];
return (
<>
<section className={s.hero}>
<AnimateOnScroll direction="none">
<h1 className={s.title}>{t('dev_title')}</h1>
<p className={s.subtitle}>{t('dev_subtitle')}</p>
</AnimateOnScroll>
</section>
<div className={s.grid}>
{resources.map((r, i) => (
<AnimateOnScroll key={r.titleKey} delay={i * 0.1}>
<div className={s.card}>
<div className={s.cardIcon}>{r.icon}</div>
<h3 className={s.cardTitle}>{t(r.titleKey)}</h3>
<p className={s.cardDesc}>{t(r.descKey)}</p>
<a href={r.link} className={s.cardLink}>{t(r.linkKey)} &rarr;</a>
</div>
</AnimateOnScroll>
))}
</div>
<section className={s.quickstart}>
<AnimateOnScroll>
<h2 className={s.qsTitle}>{t('dev_quickstart')}</h2>
<div className={s.codeBlock}>
<pre>{`// Install the Genex SDK\nnpm install @genex/sdk\n\n// Initialize the client\nimport { GenexClient } from '@genex/sdk';\n\nconst client = new GenexClient({\n apiKey: 'your-api-key',\n network: 'mainnet',\n});\n\n// Query available coupons\nconst coupons = await client.coupons.list({\n category: 'dining',\n maxPrice: 100,\n});\n\nconsole.log(coupons);`}</pre>
</div>
</AnimateOnScroll>
</section>
</>
);
}

View File

@ -0,0 +1,36 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function DownloadPage() {
const t = useT();
const platforms = [
{ icon: '\u{1F4F1}', titleKey: 'download_ios_title', descKey: 'download_ios_desc', btnKey: 'download_ios_btn' },
{ icon: '\u{1F4F2}', titleKey: 'download_android_title', descKey: 'download_android_desc', btnKey: 'download_android_btn' },
{ icon: '\u{1F4BB}', titleKey: 'download_web_title', descKey: 'download_web_desc', btnKey: 'download_web_btn' },
{ icon: '\u{2B50}', titleKey: 'download_miniapp_title', descKey: 'download_miniapp_desc', btnKey: 'download_miniapp_btn' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('download_title')}</h1>
<p className={s.subtitle}>{t('download_subtitle')}</p>
</section>
<div className={s.grid} style={{ gridTemplateColumns: 'repeat(2, 1fr)', maxWidth: 800 }}>
{platforms.map((p, i) => (
<AnimateOnScroll key={p.titleKey} delay={i * 0.1}>
<div className={s.card} style={{ textAlign: 'center' }}>
<div className={s.cardIcon}>{p.icon}</div>
<h3 className={s.cardTitle}>{t(p.titleKey)}</h3>
<p className={s.cardDesc}>{t(p.descKey)}</p>
<span className={s.cardLink}>{t(p.btnKey)} &rarr;</span>
</div>
</AnimateOnScroll>
))}
</div>
</>
);
}

View File

@ -0,0 +1,70 @@
.hero {
padding: 160px 24px 80px;
text-align: center;
background: linear-gradient(180deg, var(--color-primary-surface) 0%, var(--color-surface) 100%);
}
.title {
font-size: 48px;
font-weight: 700;
margin-bottom: 16px;
}
.subtitle {
font-size: 20px;
color: var(--color-text-secondary);
max-width: 600px;
margin: 0 auto;
}
.grid {
max-width: var(--max-width);
margin: 0 auto;
padding: 80px 24px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
}
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 48px 40px;
border: 1px solid var(--color-border-light);
transition: all 0.3s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-primary-container);
}
.cardIcon {
width: 64px;
height: 64px;
border-radius: 18px;
background: var(--color-primary-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 24px;
}
.cardTitle {
font-size: 22px;
font-weight: 600;
margin-bottom: 12px;
}
.cardDesc {
font-size: 15px;
color: var(--color-text-secondary);
line-height: 1.7;
}
@media (max-width: 768px) {
.title { font-size: 32px; }
.grid { grid-template-columns: 1fr; }
}

View File

@ -0,0 +1,42 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from './page.module.css';
export default function FeaturesPage() {
const t = useT();
const features = [
{ icon: '\u{1F6D2}', titleKey: 'feat_buy_title', descKey: 'feat_buy_desc' },
{ icon: '\u{1F4C8}', titleKey: 'feat_market_title', descKey: 'feat_market_desc' },
{ icon: '\u{1F4DC}', titleKey: 'feat_contract_title', descKey: 'feat_contract_desc' },
{ icon: '\u{2B50}', titleKey: 'feat_rating_title', descKey: 'feat_rating_desc' },
{ icon: '\u{1F4F1}', titleKey: 'feat_multi_title', descKey: 'feat_multi_desc' },
{ icon: '\u{1F916}', titleKey: 'feat_ai_title', descKey: 'feat_ai_desc' },
];
return (
<>
<section className={s.hero}>
<AnimateOnScroll direction="none">
<h1 className={s.title}>{t('features_title')}</h1>
<p className={s.subtitle}>{t('features_subtitle')}</p>
</AnimateOnScroll>
</section>
<div className={s.grid}>
{features.map((f, i) => (
<AnimateOnScroll key={f.titleKey} delay={i * 0.1}>
<div className={s.card}>
<div className={s.cardIcon}>{f.icon}</div>
<h3 className={s.cardTitle}>{t(f.titleKey)}</h3>
<p className={s.cardDesc}>{t(f.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</>
);
}

View File

@ -0,0 +1,135 @@
/* ============================================================
Genex Portal - Global Styles + Design Tokens
============================================================ */
:root {
/* ---- Primary Purple ---- */
--color-primary: #6C5CE7;
--color-primary-light: #9B8FFF;
--color-primary-dark: #4834D4;
--color-primary-surface: #F3F1FF;
--color-primary-container: #E8E5FF;
/* ---- Neutral ---- */
--color-gray-50: #F8F9FC;
--color-gray-100: #F1F3F8;
--color-gray-200: #E4E7F0;
--color-gray-300: #CDD2DE;
--color-gray-400: #A0A8BE;
--color-gray-500: #7A839E;
--color-gray-600: #5C6478;
--color-gray-700: #3D4459;
--color-gray-800: #262B3A;
--color-gray-900: #141723;
/* ---- Semantic ---- */
--color-success: #00C48C;
--color-warning: #FFAB2E;
--color-error: #FF4757;
--color-info: #3B82F6;
/* ---- Background & Surface ---- */
--color-bg: #F8F9FC;
--color-surface: #FFFFFF;
/* ---- Text ---- */
--color-text-primary: #141723;
--color-text-secondary: #5C6478;
--color-text-tertiary: #A0A8BE;
--color-text-on-primary: #FFFFFF;
/* ---- Border ---- */
--color-border: #E4E7F0;
--color-border-light: #F1F3F8;
/* ---- Typography ---- */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans SC', sans-serif;
--font-family-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* ---- Spacing ---- */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 20px;
--space-2xl: 24px;
--space-3xl: 32px;
--space-4xl: 40px;
/* ---- Radius ---- */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 999px;
/* ---- Shadow ---- */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 4px 16px rgba(108, 92, 231, 0.2);
/* ---- Layout ---- */
--header-height: 72px;
--max-width: 1200px;
}
/* ---- Reset ---- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-family);
color: var(--color-text-primary);
background: var(--color-surface);
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
img, svg {
display: block;
max-width: 100%;
}
button {
font-family: inherit;
cursor: pointer;
}
/* ---- Utility ---- */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 24px;
}
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
}
/* ---- Scrollbar ---- */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-300);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@ -0,0 +1,42 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function HelpPage() {
const t = useT();
const topics = [
{ icon: '\u{1F6D2}', titleKey: 'help_t1_title', descKey: 'help_t1_desc' },
{ icon: '\u{1F4B1}', titleKey: 'help_t2_title', descKey: 'help_t2_desc' },
{ icon: '\u{1F4B3}', titleKey: 'help_t3_title', descKey: 'help_t3_desc' },
{ icon: '\u{1F512}', titleKey: 'help_t4_title', descKey: 'help_t4_desc' },
{ icon: '\u{1F4F1}', titleKey: 'help_t5_title', descKey: 'help_t5_desc' },
{ icon: '\u{2753}', titleKey: 'help_t6_title', descKey: 'help_t6_desc' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('help_title')}</h1>
<p className={s.subtitle}>{t('help_subtitle')}</p>
</section>
<div className={s.grid}>
{topics.map((tp, i) => (
<AnimateOnScroll key={tp.titleKey} delay={i * 0.1}>
<div className={s.card}>
<div className={s.cardIcon}>{tp.icon}</div>
<h3 className={s.cardTitle}>{t(tp.titleKey)}</h3>
<p className={s.cardDesc}>{t(tp.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
<div style={{ textAlign: 'center', padding: '0 24px 80px' }}>
<p style={{ fontSize: 16, color: 'var(--color-text-secondary)', marginBottom: 16 }}>{t('help_contact_hint')}</p>
<Link href="/contact" style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-primary)' }}>{t('help_contact_link')} &rarr;</Link>
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
'use client';
import React, { useState } from 'react';
import './globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import { LocaleContext, type Locale } from '@/i18n';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [locale, setLocale] = useState<Locale>('zh-CN');
return (
<html lang={locale}>
<head>
<title>Genex - </title>
<meta name="description" content="区块链驱动的券资产交易平台,折扣购券、自由交易、安全保障" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<LocaleContext.Provider value={{ locale, setLocale }}>
<Header />
<main>{children}</main>
<Footer />
</LocaleContext.Provider>
</body>
</html>
);
}

View File

@ -0,0 +1,374 @@
/* ---- Hero ---- */
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #4834D4 0%, #2d1b69 40%, #1a1145 100%);
overflow: hidden;
padding: 120px 24px 80px;
}
.heroBg {
position: absolute;
inset: 0;
overflow: hidden;
}
.heroBg::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(108, 92, 231, 0.3) 0%, transparent 70%);
top: -200px;
right: -100px;
animation: floatSlow 12s ease-in-out infinite;
}
.heroBg::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(155, 143, 255, 0.2) 0%, transparent 70%);
bottom: -100px;
left: -50px;
animation: floatSlow 10s ease-in-out infinite reverse;
}
@keyframes floatSlow {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, -20px) scale(1.05); }
}
.heroContent {
position: relative;
z-index: 1;
text-align: center;
max-width: 800px;
}
.heroTitle {
font-size: 64px;
font-weight: 800;
color: #fff;
line-height: 1.15;
letter-spacing: -1px;
margin-bottom: 24px;
}
.heroSubtitle {
font-size: 20px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
margin-bottom: 48px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.heroBtns {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.btnPrimary {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-full);
padding: 16px 40px;
font-size: 17px;
font-weight: 600;
transition: all 0.3s;
box-shadow: 0 4px 24px rgba(108, 92, 231, 0.4);
}
.btnPrimary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(108, 92, 231, 0.5);
}
.btnOutline {
background: transparent;
color: #fff;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-full);
padding: 14px 40px;
font-size: 17px;
font-weight: 600;
transition: all 0.3s;
}
.btnOutline:hover {
border-color: #fff;
background: rgba(255, 255, 255, 0.1);
}
/* ---- Stats ---- */
.stats {
background: linear-gradient(135deg, #2d1b69, #1a1145);
padding: 64px 24px;
}
.statsGrid {
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
}
/* ---- Section Generic ---- */
.section {
padding: 100px 24px;
}
.sectionDark {
padding: 100px 24px;
background: var(--color-gray-50);
}
.sectionTitle {
font-size: 40px;
font-weight: 700;
text-align: center;
margin-bottom: 16px;
color: var(--color-text-primary);
}
.sectionSubtitle {
font-size: 18px;
color: var(--color-text-secondary);
text-align: center;
margin-bottom: 64px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* ---- Advantages ---- */
.advGrid {
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 32px;
}
.advCard {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 40px 32px;
text-align: center;
border: 1px solid var(--color-border-light);
transition: all 0.3s;
}
.advCard:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-primary-container);
}
.advIcon {
width: 72px;
height: 72px;
border-radius: 20px;
background: var(--color-primary-surface);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
font-size: 32px;
}
.advTitle {
font-size: 22px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text-primary);
}
.advDesc {
font-size: 15px;
color: var(--color-text-secondary);
line-height: 1.6;
}
/* ---- Product Showcase ---- */
.prodGrid {
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.prodCard {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 36px 32px;
border: 1px solid var(--color-border-light);
display: flex;
gap: 20px;
align-items: flex-start;
transition: all 0.3s;
}
.prodCard:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary-container);
}
.prodIcon {
width: 52px;
height: 52px;
border-radius: 14px;
background: var(--color-primary-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.prodTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: var(--color-text-primary);
}
.prodDesc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.6;
}
/* ---- Trust ---- */
.trustGrid {
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.trustCard {
text-align: center;
padding: 32px 24px;
}
.trustIcon {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--color-primary-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin: 0 auto 20px;
}
.trustTitle {
font-size: 17px;
font-weight: 600;
margin-bottom: 10px;
color: var(--color-text-primary);
}
.trustDesc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.6;
}
/* ---- CTA ---- */
.cta {
padding: 100px 24px;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
text-align: center;
}
.ctaTitle {
font-size: 40px;
font-weight: 700;
color: #fff;
margin-bottom: 16px;
}
.ctaSubtitle {
font-size: 18px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 48px;
}
.ctaBtns {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.ctaBtn {
background: #fff;
color: var(--color-primary);
border: none;
border-radius: var(--radius-full);
padding: 14px 32px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
}
.ctaBtn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
/* ---- Responsive ---- */
@media (max-width: 768px) {
.heroTitle {
font-size: 36px;
}
.heroSubtitle {
font-size: 16px;
}
.statsGrid {
grid-template-columns: repeat(2, 1fr);
}
.sectionTitle {
font-size: 28px;
}
.advGrid {
grid-template-columns: 1fr;
max-width: 480px;
}
.prodGrid {
grid-template-columns: 1fr;
}
.trustGrid {
grid-template-columns: repeat(2, 1fr);
}
.ctaTitle {
font-size: 28px;
}
}

View File

@ -0,0 +1,120 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import StatsCounter from '@/components/StatsCounter';
import s from './page.module.css';
export default function HomePage() {
const t = useT();
return (
<>
{/* ===== Hero ===== */}
<section className={s.hero}>
<div className={s.heroBg} />
<div className={s.heroContent}>
<AnimateOnScroll direction="none">
<h1 className={s.heroTitle}>{t('hero_title')}</h1>
<p className={s.heroSubtitle}>{t('hero_subtitle')}</p>
<div className={s.heroBtns}>
<button className={s.btnPrimary}>{t('hero_cta_download')}</button>
<button className={s.btnOutline}>{t('hero_cta_register')}</button>
</div>
</AnimateOnScroll>
</div>
</section>
{/* ===== Stats ===== */}
<section className={s.stats}>
<div className={s.statsGrid}>
<StatsCounter end={12.8} suffix={t('stats_unit_yi')} label={t('stats_volume')} decimals={1} />
<StatsCounter end={56} suffix={t('stats_unit_wan')} label={t('stats_users')} />
<StatsCounter end={3200} suffix={t('stats_unit_ge')} label={t('stats_coupons')} />
<StatsCounter end={860} suffix={t('stats_unit_ge')} label={t('stats_merchants')} />
</div>
</section>
{/* ===== Advantages ===== */}
<section className={s.section}>
<h2 className={s.sectionTitle}>{t('adv_title')}</h2>
<div className={s.sectionSubtitle} />
<div className={s.advGrid}>
{[
{ icon: '\u{1F3F7}', titleKey: 'adv_discount_title', descKey: 'adv_discount_desc' },
{ icon: '\u{1F4C8}', titleKey: 'adv_trade_title', descKey: 'adv_trade_desc' },
{ icon: '\u{1F6E1}', titleKey: 'adv_secure_title', descKey: 'adv_secure_desc' },
].map((item, i) => (
<AnimateOnScroll key={item.titleKey} delay={i * 0.15}>
<div className={s.advCard}>
<div className={s.advIcon}>{item.icon}</div>
<h3 className={s.advTitle}>{t(item.titleKey)}</h3>
<p className={s.advDesc}>{t(item.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</section>
{/* ===== Product Showcase ===== */}
<section className={s.sectionDark}>
<h2 className={s.sectionTitle}>{t('prod_title')}</h2>
<p className={s.sectionSubtitle}>{t('prod_subtitle')}</p>
<div className={s.prodGrid}>
{[
{ icon: '\u{1F6D2}', titleKey: 'prod_buy', descKey: 'prod_buy_desc' },
{ icon: '\u{1F4B1}', titleKey: 'prod_trade', descKey: 'prod_trade_desc' },
{ icon: '\u{1F4F1}', titleKey: 'prod_use', descKey: 'prod_use_desc' },
{ icon: '\u{1F4CA}', titleKey: 'prod_earn', descKey: 'prod_earn_desc' },
].map((item, i) => (
<AnimateOnScroll key={item.titleKey} delay={i * 0.1}>
<div className={s.prodCard}>
<div className={s.prodIcon}>{item.icon}</div>
<div>
<h3 className={s.prodTitle}>{t(item.titleKey)}</h3>
<p className={s.prodDesc}>{t(item.descKey)}</p>
</div>
</div>
</AnimateOnScroll>
))}
</div>
</section>
{/* ===== Trust ===== */}
<section className={s.section}>
<h2 className={s.sectionTitle}>{t('trust_title')}</h2>
<div className={s.sectionSubtitle} />
<div className={s.trustGrid}>
{[
{ icon: '\u{26D3}', titleKey: 'trust_blockchain', descKey: 'trust_blockchain_desc' },
{ icon: '\u{2705}', titleKey: 'trust_compliance', descKey: 'trust_compliance_desc' },
{ icon: '\u{2B50}', titleKey: 'trust_rating', descKey: 'trust_rating_desc' },
{ icon: '\u{1F512}', titleKey: 'trust_insurance', descKey: 'trust_insurance_desc' },
].map((item, i) => (
<AnimateOnScroll key={item.titleKey} delay={i * 0.1}>
<div className={s.trustCard}>
<div className={s.trustIcon}>{item.icon}</div>
<h3 className={s.trustTitle}>{t(item.titleKey)}</h3>
<p className={s.trustDesc}>{t(item.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</section>
{/* ===== CTA ===== */}
<section className={s.cta}>
<AnimateOnScroll>
<h2 className={s.ctaTitle}>{t('cta_title')}</h2>
<p className={s.ctaSubtitle}>{t('cta_subtitle')}</p>
<div className={s.ctaBtns}>
<button className={s.ctaBtn}>{t('cta_ios')}</button>
<button className={s.ctaBtn}>{t('cta_android')}</button>
<button className={s.ctaBtn}>{t('cta_miniapp')}</button>
</div>
</AnimateOnScroll>
</section>
</>
);
}

View File

@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function PressPage() {
const t = useT();
const items = [
{ dateKey: 'press_p1_date', titleKey: 'press_p1_title', descKey: 'press_p1_desc' },
{ dateKey: 'press_p2_date', titleKey: 'press_p2_title', descKey: 'press_p2_desc' },
{ dateKey: 'press_p3_date', titleKey: 'press_p3_title', descKey: 'press_p3_desc' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('press_title')}</h1>
<p className={s.subtitle}>{t('press_subtitle')}</p>
</section>
<div className={s.body}>
{items.map((item, i) => (
<AnimateOnScroll key={item.titleKey} delay={i * 0.1}>
<div style={{ marginBottom: 40, paddingBottom: 40, borderBottom: '1px solid var(--color-border-light)' }}>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>{t(item.dateKey)}</div>
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 10 }}>{t(item.titleKey)}</h3>
<p style={{ fontSize: 15, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{t(item.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import s from '../content.module.css';
export default function PrivacyPage() {
const t = useT();
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('privacy_title')}</h1>
</section>
<div className={s.body}>
<p className={s.updated}>{t('privacy_updated')}</p>
<h2>{t('privacy_s1_title')}</h2>
<p>{t('privacy_s1_p1')}</p>
<h2>{t('privacy_s2_title')}</h2>
<p>{t('privacy_s2_p1')}</p>
<ul>
<li>{t('privacy_s2_li1')}</li>
<li>{t('privacy_s2_li2')}</li>
<li>{t('privacy_s2_li3')}</li>
<li>{t('privacy_s2_li4')}</li>
</ul>
<h2>{t('privacy_s3_title')}</h2>
<p>{t('privacy_s3_p1')}</p>
<h2>{t('privacy_s4_title')}</h2>
<p>{t('privacy_s4_p1')}</p>
<h2>{t('privacy_s5_title')}</h2>
<p>{t('privacy_s5_p1')}</p>
<h2>{t('privacy_s6_title')}</h2>
<p>{t('privacy_s6_p1')}</p>
</div>
</>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import s from '../content.module.css';
export default function RiskPage() {
const t = useT();
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('risk_title')}</h1>
</section>
<div className={s.body}>
<p className={s.updated}>{t('risk_updated')}</p>
<h2>{t('risk_s1_title')}</h2>
<p>{t('risk_s1_p1')}</p>
<h2>{t('risk_s2_title')}</h2>
<ul>
<li>{t('risk_s2_li1')}</li>
<li>{t('risk_s2_li2')}</li>
<li>{t('risk_s2_li3')}</li>
<li>{t('risk_s2_li4')}</li>
</ul>
<h2>{t('risk_s3_title')}</h2>
<p>{t('risk_s3_p1')}</p>
<h2>{t('risk_s4_title')}</h2>
<p>{t('risk_s4_p1')}</p>
</div>
</>
);
}

View File

@ -0,0 +1,88 @@
.hero {
padding: 160px 24px 80px;
text-align: center;
background: linear-gradient(180deg, var(--color-primary-surface) 0%, var(--color-surface) 100%);
}
.title {
font-size: 48px;
font-weight: 700;
margin-bottom: 16px;
}
.subtitle {
font-size: 20px;
color: var(--color-text-secondary);
max-width: 600px;
margin: 0 auto;
}
/* ---- Solution Block ---- */
.block {
padding: 80px 24px;
}
.blockAlt {
padding: 80px 24px;
background: var(--color-gray-50);
}
.blockHeader {
max-width: var(--max-width);
margin: 0 auto 48px;
}
.blockTitle {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
}
.blockSubtitle {
font-size: 17px;
color: var(--color-text-secondary);
}
.solGrid {
max-width: var(--max-width);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
}
.solCard {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 36px 28px;
border: 1px solid var(--color-border-light);
transition: all 0.3s;
}
.solCard:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.solIcon {
font-size: 36px;
margin-bottom: 20px;
}
.solTitle {
font-size: 20px;
font-weight: 600;
margin-bottom: 10px;
}
.solDesc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.7;
}
@media (max-width: 768px) {
.title { font-size: 32px; }
.blockTitle { font-size: 24px; }
.solGrid { grid-template-columns: 1fr; max-width: 480px; }
}

View File

@ -0,0 +1,75 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from './page.module.css';
export default function SolutionsPage() {
const t = useT();
const consumerSolutions = [
{ icon: '\u{1F4B0}', titleKey: 'sol_c_save_title', descKey: 'sol_c_save_desc' },
{ icon: '\u{1F4C8}', titleKey: 'sol_c_invest_title', descKey: 'sol_c_invest_desc' },
{ icon: '\u{1F381}', titleKey: 'sol_c_gift_title', descKey: 'sol_c_gift_desc' },
];
const merchantSolutions = [
{ icon: '\u{1F3AF}', titleKey: 'sol_m_acquire_title', descKey: 'sol_m_acquire_desc' },
{ icon: '\u{1F4B5}', titleKey: 'sol_m_finance_title', descKey: 'sol_m_finance_desc' },
{ icon: '\u{1F4CA}', titleKey: 'sol_m_loyalty_title', descKey: 'sol_m_loyalty_desc' },
];
return (
<>
<section className={s.hero}>
<AnimateOnScroll direction="none">
<h1 className={s.title}>{t('solutions_title')}</h1>
<p className={s.subtitle}>{t('solutions_subtitle')}</p>
</AnimateOnScroll>
</section>
{/* Consumer */}
<section className={s.block}>
<AnimateOnScroll>
<div className={s.blockHeader}>
<h2 className={s.blockTitle}>{t('sol_consumer_title')}</h2>
<p className={s.blockSubtitle}>{t('sol_consumer_subtitle')}</p>
</div>
</AnimateOnScroll>
<div className={s.solGrid}>
{consumerSolutions.map((sol, i) => (
<AnimateOnScroll key={sol.titleKey} delay={i * 0.1}>
<div className={s.solCard}>
<div className={s.solIcon}>{sol.icon}</div>
<h3 className={s.solTitle}>{t(sol.titleKey)}</h3>
<p className={s.solDesc}>{t(sol.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</section>
{/* Merchant */}
<section className={s.blockAlt}>
<AnimateOnScroll>
<div className={s.blockHeader}>
<h2 className={s.blockTitle}>{t('sol_merchant_title')}</h2>
<p className={s.blockSubtitle}>{t('sol_merchant_subtitle')}</p>
</div>
</AnimateOnScroll>
<div className={s.solGrid}>
{merchantSolutions.map((sol, i) => (
<AnimateOnScroll key={sol.titleKey} delay={i * 0.1}>
<div className={s.solCard}>
<div className={s.solIcon}>{sol.icon}</div>
<h3 className={s.solTitle}>{t(sol.titleKey)}</h3>
<p className={s.solDesc}>{t(sol.descKey)}</p>
</div>
</AnimateOnScroll>
))}
</div>
</section>
</>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import AnimateOnScroll from '@/components/AnimateOnScroll';
import s from '../content.module.css';
export default function StatusPage() {
const t = useT();
const services = [
{ nameKey: 'status_s1_name', statusKey: 'status_s1_status' },
{ nameKey: 'status_s2_name', statusKey: 'status_s2_status' },
{ nameKey: 'status_s3_name', statusKey: 'status_s3_status' },
{ nameKey: 'status_s4_name', statusKey: 'status_s4_status' },
{ nameKey: 'status_s5_name', statusKey: 'status_s5_status' },
{ nameKey: 'status_s6_name', statusKey: 'status_s6_status' },
];
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('status_title')}</h1>
<p className={s.subtitle}>{t('status_subtitle')}</p>
</section>
<div className={s.body}>
<AnimateOnScroll>
<div style={{ background: 'var(--color-success-surface, #e8f5e9)', borderRadius: 'var(--radius-lg)', padding: '24px 32px', marginBottom: 40, display: 'flex', alignItems: 'center', gap: 12 }}>
<span className={s.statusDot} />
<span style={{ fontSize: 18, fontWeight: 600, color: 'var(--color-success)' }}>{t('status_all_ok')}</span>
</div>
</AnimateOnScroll>
{services.map((svc, i) => (
<AnimateOnScroll key={svc.nameKey} delay={i * 0.05}>
<div className={s.statusRow}>
<div className={s.statusLabel}>
<span className={s.statusDot} />
<span>{t(svc.nameKey)}</span>
</div>
<span className={s.statusBadge}>{t(svc.statusKey)}</span>
</div>
</AnimateOnScroll>
))}
<p style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginTop: 32, textAlign: 'center' }}>{t('status_updated')}</p>
</div>
</>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import React from 'react';
import { useT } from '@/i18n';
import s from '../content.module.css';
export default function TermsPage() {
const t = useT();
return (
<>
<section className={s.hero}>
<h1 className={s.title}>{t('terms_title')}</h1>
</section>
<div className={s.body}>
<p className={s.updated}>{t('terms_updated')}</p>
<h2>{t('terms_s1_title')}</h2>
<p>{t('terms_s1_p1')}</p>
<p>{t('terms_s1_p2')}</p>
<h2>{t('terms_s2_title')}</h2>
<p>{t('terms_s2_p1')}</p>
<ul>
<li>{t('terms_s2_li1')}</li>
<li>{t('terms_s2_li2')}</li>
<li>{t('terms_s2_li3')}</li>
</ul>
<h2>{t('terms_s3_title')}</h2>
<p>{t('terms_s3_p1')}</p>
<p>{t('terms_s3_p2')}</p>
<h2>{t('terms_s4_title')}</h2>
<p>{t('terms_s4_p1')}</p>
<h2>{t('terms_s5_title')}</h2>
<p>{t('terms_s5_p1')}</p>
<h2>{t('terms_s6_title')}</h2>
<p>{t('terms_s6_p1')}</p>
</div>
</>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
interface Props {
children: React.ReactNode;
className?: string;
delay?: number;
direction?: 'up' | 'down' | 'left' | 'right' | 'none';
}
const offsets = {
up: { y: 40 },
down: { y: -40 },
left: { x: 40 },
right: { x: -40 },
none: {},
};
export default function AnimateOnScroll({
children,
className,
delay = 0,
direction = 'up',
}: Props) {
return (
<motion.div
className={className}
initial={{ opacity: 0, ...offsets[direction] }}
whileInView={{ opacity: 1, x: 0, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay, ease: 'easeOut' }}
>
{children}
</motion.div>
);
}

View File

@ -0,0 +1,104 @@
.footer {
background: var(--color-gray-900);
color: rgba(255, 255, 255, 0.7);
padding: 80px 0 32px;
}
.top {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 24px;
display: grid;
grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr;
gap: 48px;
}
.brandCol {
display: flex;
flex-direction: column;
gap: 16px;
}
.logoRow {
display: flex;
align-items: center;
gap: 10px;
}
.brandName {
font-size: 22px;
font-weight: 700;
color: #fff;
}
.slogan {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
line-height: 1.6;
}
.colTitle {
font-size: 14px;
font-weight: 600;
color: #fff;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.colLinks {
display: flex;
flex-direction: column;
gap: 10px;
}
.colLink {
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
transition: color 0.2s;
}
.colLink:hover {
color: var(--color-primary-light);
}
.divider {
max-width: var(--max-width);
margin: 48px auto 0;
padding: 0 24px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.bottom {
max-width: var(--max-width);
margin: 0 auto;
padding: 24px 24px 0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
}
@media (max-width: 900px) {
.top {
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.brandCol {
grid-column: 1 / -1;
}
}
@media (max-width: 480px) {
.top {
grid-template-columns: 1fr;
}
.bottom {
flex-direction: column;
gap: 8px;
text-align: center;
}
}

View File

@ -0,0 +1,84 @@
'use client';
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useT } from '@/i18n';
import s from './Footer.module.css';
export default function Footer() {
const t = useT();
const columns = [
{
title: t('footer_product'),
links: [
{ label: t('footer_mobile_app'), href: '/download' },
{ label: t('footer_miniapp'), href: '/download' },
{ label: t('footer_web_trade'), href: '/download' },
{ label: t('footer_api'), href: '/developers' },
],
},
{
title: t('footer_company'),
links: [
{ label: t('footer_about'), href: '/about' },
{ label: t('footer_careers'), href: '/careers' },
{ label: t('footer_blog'), href: '/blog' },
{ label: t('footer_press'), href: '/press' },
],
},
{
title: t('footer_support'),
links: [
{ label: t('footer_help'), href: '/help' },
{ label: t('footer_contact'), href: '/contact' },
{ label: t('footer_community'), href: '/community' },
{ label: t('footer_status'), href: '/status' },
],
},
{
title: t('footer_legal'),
links: [
{ label: t('footer_terms'), href: '/terms' },
{ label: t('footer_privacy'), href: '/privacy' },
{ label: t('footer_cookie'), href: '/cookie-policy' },
{ label: t('footer_risk'), href: '/risk' },
],
},
];
return (
<footer className={s.footer}>
<div className={s.top}>
<div className={s.brandCol}>
<div className={s.logoRow}>
<Image src="/logo.svg" alt="Genex" width={32} height={32} />
<span className={s.brandName}>Genex</span>
</div>
<p className={s.slogan}>{t('footer_slogan')}</p>
</div>
{columns.map((col) => (
<div key={col.title}>
<div className={s.colTitle}>{col.title}</div>
<div className={s.colLinks}>
{col.links.map((link) => (
<Link key={link.label} href={link.href} className={s.colLink}>
{link.label}
</Link>
))}
</div>
</div>
))}
</div>
<div className={s.divider} />
<div className={s.bottom}>
<span>{t('footer_copyright')}</span>
<span>{t('footer_icp')}</span>
</div>
</footer>
);
}

View File

@ -0,0 +1,215 @@
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
z-index: 1000;
transition: background 0.3s, box-shadow 0.3s;
}
.header.scrolled {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-sm);
}
.inner {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 24px;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.logoArea {
display: flex;
align-items: center;
gap: 10px;
}
.brand {
font-size: 22px;
font-weight: 700;
color: var(--color-primary);
transition: color 0.3s;
}
.scrolled .brand {
color: var(--color-primary);
}
.transparentText .brand {
color: #fff;
}
.nav {
display: flex;
align-items: center;
gap: 32px;
}
.navLink {
font-size: 15px;
font-weight: 500;
color: var(--color-text-secondary);
transition: color 0.2s;
position: relative;
}
.navLink:hover {
color: var(--color-primary);
}
.navLink.active {
color: var(--color-primary);
}
.transparentText .navLink {
color: rgba(255, 255, 255, 0.85);
}
.transparentText .navLink:hover,
.transparentText .navLink.active {
color: #fff;
}
.scrolled .navLink {
color: var(--color-text-secondary);
}
.scrolled .navLink:hover,
.scrolled .navLink.active {
color: var(--color-primary);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.langBtn {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
transition: all 0.2s;
}
.langBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.transparentText .langBtn {
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.85);
}
.transparentText .langBtn:hover {
border-color: #fff;
color: #fff;
}
.scrolled .langBtn {
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.ctaBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-full);
padding: 8px 20px;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.ctaBtn:hover {
background: var(--color-primary-dark);
box-shadow: var(--shadow-primary);
}
/* Mobile actions (hidden on desktop) */
.mobileActions {
display: none;
}
/* Mobile menu */
.menuToggle {
display: none;
background: none;
border: none;
width: 32px;
height: 32px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
}
.menuToggle span {
display: block;
width: 22px;
height: 2px;
background: var(--color-text-primary);
transition: all 0.3s;
border-radius: 1px;
}
.transparentText .menuToggle span {
background: #fff;
}
.scrolled .menuToggle span {
background: var(--color-text-primary);
}
@media (max-width: 900px) {
.nav {
display: none;
}
.nav.mobileOpen {
display: flex;
flex-direction: column;
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(12px);
padding: 32px 24px;
gap: 24px;
z-index: 999;
}
.nav.mobileOpen .navLink {
font-size: 18px;
color: var(--color-text-primary);
}
.menuToggle {
display: flex;
}
.actions {
display: none;
}
.nav.mobileOpen .mobileActions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
}

View File

@ -0,0 +1,97 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useT, useLocale } from '@/i18n';
import type { Locale } from '@/i18n';
import s from './Header.module.css';
export default function Header() {
const t = useT();
const { locale, setLocale } = useLocale();
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const isHome = pathname === '/';
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
const transparent = isHome && !scrolled && !menuOpen;
const links = [
{ href: '/', label: t('nav_home') },
{ href: '/about', label: t('nav_about') },
{ href: '/features', label: t('nav_features') },
{ href: '/solutions', label: t('nav_solutions') },
{ href: '/developers', label: t('nav_developers') },
{ href: '/contact', label: t('nav_contact') },
];
const toggleLang = () => {
setLocale(locale === 'zh-CN' ? 'en-US' : 'zh-CN' as Locale);
};
const cls = [
s.header,
scrolled || menuOpen ? s.scrolled : '',
transparent ? s.transparentText : '',
].join(' ');
return (
<header className={cls}>
<div className={s.inner}>
<Link href="/" className={s.logoArea}>
<Image src="/logo.svg" alt="Genex" width={36} height={36} />
<span className={s.brand}>Genex</span>
</Link>
<nav className={`${s.nav} ${menuOpen ? s.mobileOpen : ''}`}>
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className={`${s.navLink} ${pathname === l.href ? s.active : ''}`}
>
{l.label}
</Link>
))}
<div className={s.mobileActions}>
<button className={s.langBtn} onClick={toggleLang}>
{locale === 'zh-CN' ? 'EN' : '中文'}
</button>
<button className={s.ctaBtn}>{t('nav_register')}</button>
</div>
</nav>
<div className={s.actions}>
<button className={s.langBtn} onClick={toggleLang}>
{locale === 'zh-CN' ? 'EN' : '中文'}
</button>
<button className={s.ctaBtn}>{t('nav_register')}</button>
</div>
<button
className={s.menuToggle}
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Menu"
>
<span />
<span />
<span />
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { motion, useInView } from 'framer-motion';
interface Props {
end: number;
suffix?: string;
label: string;
decimals?: number;
duration?: number;
}
export default function StatsCounter({
end,
suffix = '',
label,
decimals = 0,
duration = 2,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: '-40px' });
const [value, setValue] = useState(0);
useEffect(() => {
if (!inView) return;
const start = 0;
const startTime = performance.now();
const tick = (now: number) => {
const elapsed = (now - startTime) / 1000;
const progress = Math.min(elapsed / duration, 1);
// ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setValue(start + (end - start) * eased);
if (progress < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}, [inView, end, duration]);
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
style={{ textAlign: 'center' }}
>
<div
style={{
fontSize: 48,
fontWeight: 700,
color: '#fff',
lineHeight: 1.2,
}}
>
{value.toFixed(decimals)}
<span style={{ fontSize: 24, marginLeft: 4 }}>{suffix}</span>
</div>
<div
style={{
fontSize: 15,
color: 'rgba(255,255,255,0.6)',
marginTop: 8,
}}
>
{label}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,375 @@
const enUS: Record<string, string> = {
// -- Nav --
nav_home: 'Home',
nav_about: 'About',
nav_features: 'Features',
nav_solutions: 'Solutions',
nav_developers: 'Developers',
nav_contact: 'Contact',
nav_download: 'Download App',
nav_register: 'Sign Up',
// -- Hero --
hero_title: 'Every Coupon, Real Value',
hero_subtitle: 'Blockchain-powered coupon asset trading platform — buy at discount, trade freely, stay secure',
hero_cta_download: 'Download App',
hero_cta_register: 'Sign Up Free',
// -- Stats --
stats_volume: 'Total Volume',
stats_users: 'Active Users',
stats_coupons: 'Coupons Issued',
stats_merchants: 'Partner Merchants',
stats_unit_yi: 'B',
stats_unit_wan: 'K+',
stats_unit_ge: '+',
// -- Advantages --
adv_title: 'Why Genex',
adv_discount_title: 'Buy at Discount',
adv_discount_desc: 'Purchase coupons below face value and save on every transaction',
adv_trade_title: 'Trade Freely',
adv_trade_desc: 'Buy and sell on the secondary market with transparent pricing',
adv_secure_title: 'Secure & Safe',
adv_secure_desc: 'Smart contract settlement with blockchain security and privacy protection',
// -- Product --
prod_title: 'All-in-One Coupon Trading',
prod_subtitle: 'From purchase to trade to redemption — intelligent management at every step',
prod_buy: 'Easy Purchase',
prod_buy_desc: 'Browse curated coupons and buy at a discount with one tap',
prod_trade: 'Flexible Trading',
prod_trade_desc: 'Place orders and trade like stocks — limit and market orders supported',
prod_use: 'Quick Redemption',
prod_use_desc: 'Scan to redeem, settle directly with the merchant',
prod_earn: 'Portfolio Tracking',
prod_earn_desc: 'Monitor your holdings in real time with smart trading insights',
// -- Trust --
trust_title: 'A Platform You Can Trust',
trust_blockchain: 'Blockchain Tech',
trust_blockchain_desc: 'Every transaction is recorded on-chain and immutable',
trust_compliance: 'Fully Compliant',
trust_compliance_desc: 'Licensed operations in strict compliance with financial regulations',
trust_rating: 'Credit Rating',
trust_rating_desc: 'Transparent merchant credit rating system for confident choices',
trust_insurance: 'Fund Protection',
trust_insurance_desc: 'Insurance mechanism safeguards every transaction',
// -- CTA --
cta_title: 'Start Smart Spending Today',
cta_subtitle: 'Download Genex App and enjoy the benefits of discounted coupons',
cta_ios: 'App Store',
cta_android: 'Google Play',
cta_miniapp: 'WeChat Mini',
// -- Footer --
footer_slogan: 'Blockchain-Powered Coupon Finance Platform',
footer_product: 'Product',
footer_company: 'Company',
footer_support: 'Support',
footer_legal: 'Legal',
footer_mobile_app: 'Mobile App',
footer_miniapp: 'WeChat Mini',
footer_web_trade: 'Web Trading',
footer_api: 'API Docs',
footer_about: 'About Us',
footer_careers: 'Careers',
footer_blog: 'Blog',
footer_press: 'Press',
footer_help: 'Help Center',
footer_contact: 'Contact Us',
footer_community: 'Community',
footer_status: 'System Status',
footer_terms: 'Terms of Service',
footer_privacy: 'Privacy Policy',
footer_cookie: 'Cookie Policy',
footer_risk: 'Risk Disclosure',
footer_icp: '',
footer_copyright: '© 2025 Genex. All rights reserved.',
// -- About --
about_title: 'About Genex',
about_subtitle: 'Making Consumption More Valuable',
about_vision_title: 'Our Vision',
about_vision_desc: 'Build an open, transparent, and efficient coupon asset trading ecosystem where every coupon can be discovered, traded, and realized.',
about_mission_title: 'Our Mission',
about_mission_desc: 'Break down information barriers through blockchain technology — real savings for consumers and efficient customer acquisition for merchants.',
about_value_title: 'Core Values',
about_value_1: 'User First',
about_value_1_desc: 'Always driven by user needs, delivering the best product experience',
about_value_2: 'Transparency',
about_value_2_desc: 'All transactions are open and transparent, credit ratings are fair',
about_value_3: 'Innovation',
about_value_3_desc: 'Continuous investment in cutting-edge technology R&D',
about_value_4: 'Compliance',
about_value_4_desc: 'Strict adherence to laws and regulations for platform stability',
about_milestone_title: 'Milestones',
about_milestone_2025_q1: 'Project launched, core architecture designed',
about_milestone_2025_q2: 'Smart contracts completed, testnet live',
about_milestone_2025_q3: 'Mobile App & Admin dashboard released',
about_milestone_2025_q4: 'Mainnet launch, first merchants onboarded',
about_milestone_2026_q1: 'Secondary market trading goes live',
about_milestone_2026_q2: 'Users surpass 100K, ecosystem expanding',
about_partners_title: 'Partners',
// -- Features --
features_title: 'Powerful Features',
features_subtitle: 'Full lifecycle coverage from issuance to trading to redemption',
feat_buy_title: 'Smart Purchase',
feat_buy_desc: 'Browse thousands of coupons with AI-powered recommendations. Filter by category, sort by price, and filter by credit rating.',
feat_market_title: 'Secondary Market',
feat_market_desc: 'Trade coupons like stocks. Limit orders, market orders, real-time pricing, millisecond matching engine.',
feat_contract_title: 'Contract Settlement',
feat_contract_desc: 'Blockchain smart contracts handle settlement automatically. Direct consumer-to-merchant settlement, no middleman.',
feat_rating_title: 'Credit Rating',
feat_rating_desc: 'Multi-dimensional credit scoring based on fulfillment rate, reviews, and business history.',
feat_multi_title: 'Multi-Platform',
feat_multi_desc: 'iOS, Android, WeChat Mini Program, and Web — real-time sync across all devices.',
feat_ai_title: 'AI Assistant',
feat_ai_desc: 'Built-in AI assistant for market analysis, optimal buy timing, and risk alerts.',
// -- Solutions --
solutions_title: 'Solutions',
solutions_subtitle: 'Whether you\'re a consumer or a merchant, Genex creates value for you',
sol_consumer_title: 'For Consumers',
sol_consumer_subtitle: 'A Smarter Way to Spend',
sol_c_save_title: 'Save on Shopping',
sol_c_save_desc: 'Buy dining, retail, and service coupons at 85-95% of face value — save 5-15% on daily spending',
sol_c_invest_title: 'Coupon Trading',
sol_c_invest_desc: 'Buy low, sell high on trending coupons to capture market gains',
sol_c_gift_title: 'Gift Coupons',
sol_c_gift_desc: 'Purchase premium brand coupons as gifts — practical and classy',
sol_merchant_title: 'For Merchants',
sol_merchant_subtitle: 'Efficient Customer Acquisition',
sol_m_acquire_title: 'Targeted Acquisition',
sol_m_acquire_desc: 'Issue coupons to attract target consumers at lower cost than traditional advertising',
sol_m_finance_title: 'Short-term Financing',
sol_m_finance_desc: 'Pre-sell coupons for zero-interest short-term financing, flexible and convenient',
sol_m_loyalty_title: 'Customer Insights',
sol_m_loyalty_desc: 'Analyze user profiles through coupon distribution and trading data to boost retention',
// -- Developers --
dev_title: 'Developer Center',
dev_subtitle: 'Connect to the Genex ecosystem and build your application',
dev_api_title: 'RESTful API',
dev_api_desc: 'Comprehensive REST API documentation covering issuance, trading, and settlement',
dev_sdk_title: 'SDK',
dev_sdk_desc: 'JavaScript, Go, and Dart SDKs for quick integration',
dev_explorer_title: 'Block Explorer',
dev_explorer_desc: 'Query on-chain transactions, contract state, and block information',
dev_sandbox_title: 'Sandbox',
dev_sandbox_desc: 'Full test environment to try all API features for free',
dev_quickstart: 'Quick Start',
dev_doc_link: 'View Full Docs',
dev_explorer_link: 'Open Explorer',
// -- Contact --
contact_title: 'Contact Us',
contact_subtitle: 'We\'d love to hear from you',
contact_form_name: 'Your Name',
contact_form_email: 'Email Address',
contact_form_subject: 'Subject',
contact_form_message: 'Message',
contact_form_submit: 'Send Message',
contact_form_success: 'Message sent! We\'ll get back to you soon.',
contact_office_title: 'Office',
contact_office_addr: 'Tech Park, Nanshan, Shenzhen',
contact_email_title: 'Email',
contact_email_addr: 'contact@gogenex.com',
contact_social_title: 'Follow Us',
// -- Terms of Service --
terms_title: 'Terms of Service',
terms_updated: 'Last updated: January 1, 2025',
terms_s1_title: '1. Acceptance of Terms',
terms_s1_p1: 'Welcome to the Genex platform ("Platform"). Please read these Terms of Service ("Terms") carefully before using any services provided by the Platform. By registering, logging in, or using the Platform, you acknowledge that you have read, understood, and agree to be bound by these Terms.',
terms_s1_p2: 'If you do not agree to any of these Terms, please do not use the Platform services.',
terms_s2_title: '2. Account Registration & Security',
terms_s2_p1: 'You must provide true, accurate, and complete personal information when registering an account, and update it promptly when changes occur. You are responsible for:',
terms_s2_li1: 'Keeping your account credentials secure and not transferring or lending your account to others',
terms_s2_li2: 'All activities conducted through your account',
terms_s2_li3: 'Immediately notifying the Platform of any account anomalies or security concerns',
terms_s3_title: '3. Services',
terms_s3_p1: 'The Platform provides services for browsing, purchasing, selling, trading, and redeeming coupon assets. The Platform acts solely as a transaction facilitator and does not participate in coupon issuance or redemption.',
terms_s3_p2: 'Coupon product information displayed on the Platform is provided by issuers. While the Platform conducts reasonable reviews, it does not guarantee the absolute accuracy of such information.',
terms_s4_title: '4. Trading Rules',
terms_s4_p1: 'All transactions on the Platform must comply with the published trading rules. Completed transactions are irrevocable. Users should fully understand the associated risks and make prudent decisions.',
terms_s5_title: '5. Intellectual Property',
terms_s5_p1: 'All content on the Platform, including but not limited to text, images, software, trademarks, and interface designs, is protected by intellectual property laws. No one may copy, modify, distribute, or otherwise use such content without written authorization from the Platform.',
terms_s6_title: '6. Disclaimer',
terms_s6_p1: 'To the maximum extent permitted by law, the Platform shall not be liable for losses caused by force majeure, third-party actions, or user\'s own actions. The Platform may suspend services for maintenance or upgrades and will endeavor to provide advance notice.',
// -- Privacy Policy --
privacy_title: 'Privacy Policy',
privacy_updated: 'Last updated: January 1, 2025',
privacy_s1_title: '1. Introduction',
privacy_s1_p1: 'Genex values your privacy. This Privacy Policy explains how we collect, use, store, and protect your personal information. By using the Platform, you consent to the data processing practices described in this policy.',
privacy_s2_title: '2. Information Collection',
privacy_s2_p1: 'We may collect the following types of information:',
privacy_s2_li1: 'Identity information: name, phone number, email address, ID documents (for identity verification)',
privacy_s2_li2: 'Transaction information: purchase records, transaction history, wallet balance',
privacy_s2_li3: 'Device information: device model, operating system version, unique device identifiers',
privacy_s2_li4: 'Usage data: browsing history, search keywords, feature usage frequency',
privacy_s3_title: '3. Use of Information',
privacy_s3_p1: 'We use the collected information to provide, maintain, and improve Platform services, perform identity verification and security protection, send you transaction notifications and platform updates, and conduct data analysis to optimize user experience.',
privacy_s4_title: '4. Information Sharing',
privacy_s4_p1: 'We will not sell or rent your personal information to third parties without your consent. We may share your information with: regulatory authorities for compliance requirements, counterparties necessary to complete transactions, and third-party partners with your explicit authorization.',
privacy_s5_title: '5. Data Security',
privacy_s5_p1: 'We employ industry-standard encryption and security measures to protect your personal information, including but not limited to SSL encrypted transmission, encrypted data storage, access control, and security auditing. Please note that no method of internet transmission is completely secure.',
privacy_s6_title: '6. Your Rights',
privacy_s6_p1: 'You have the right to access, correct, or delete your personal information. You may exercise these rights through account settings or by contacting customer support. To delete your account and related data, please contact our support team.',
// -- Cookie Policy --
cookie_title: 'Cookie Policy',
cookie_updated: 'Last updated: January 1, 2025',
cookie_s1_title: '1. What Are Cookies',
cookie_s1_p1: 'Cookies are small text files stored on your device when you browse a website. They help websites remember your preferences and provide a better browsing experience.',
cookie_s2_title: '2. Types of Cookies We Use',
cookie_s2_p1: 'We use the following types of cookies to enhance your experience:',
cookie_s2_h3_1: 'Essential Cookies',
cookie_s2_p2: 'Used to maintain your login status, language preferences, and security verification. These cookies are necessary for the Platform to function properly.',
cookie_s2_h3_2: 'Analytics Cookies',
cookie_s2_p3: 'Help us understand how users interact with the Platform so we can improve the product experience. This data is anonymized and aggregated.',
cookie_s2_h3_3: 'Functional Cookies',
cookie_s2_p4: 'Remember your personalized settings such as language selection and theme preferences, providing a customized experience.',
cookie_s3_title: '3. Managing Cookies',
cookie_s3_p1: 'You can manage or delete cookies through your browser settings. Please note that disabling certain cookies may affect some Platform features.',
// -- Risk Disclosure --
risk_title: 'Risk Disclosure',
risk_updated: 'Last updated: January 1, 2025',
risk_s1_title: '1. General Risk Notice',
risk_s1_p1: 'Coupon asset trading carries inherent market risks. Coupon prices may fluctuate due to market supply and demand, issuer business conditions, macroeconomic factors, and other variables. You should fully understand the associated risks before trading on the Platform.',
risk_s2_title: '2. Specific Risk Factors',
risk_s2_li1: 'Market risk: Secondary market prices may fall below your purchase price, resulting in losses',
risk_s2_li2: 'Credit risk: Issuers may fail to honor coupons due to business difficulties, causing loss of value',
risk_s2_li3: 'Liquidity risk: Some coupons may have low trading volume, making it difficult to execute trades at desired prices',
risk_s2_li4: 'Technical risk: Blockchain network congestion, system failures, and other technical factors may affect trade execution',
risk_s3_title: '3. Investment Advice',
risk_s3_p1: 'Make investment decisions based on your financial situation and risk tolerance. Do not invest more than you can afford to lose. Past trading performance does not guarantee future returns.',
risk_s4_title: '4. Disclaimer',
risk_s4_p1: 'The Platform does not provide investment advice. All information on the Platform is for reference only and does not constitute a recommendation or offer to buy or sell any coupon product. Users should make their own judgments and bear the consequences of their trading decisions.',
// -- Careers --
careers_title: 'Careers',
careers_subtitle: 'Join talented people doing meaningful work',
careers_job1_title: 'Senior Full-Stack Engineer',
careers_job1_desc: 'Build core platform features. Proficiency in TypeScript, React, and Node.js required. Experience in fintech or blockchain preferred.',
careers_job2_title: 'Blockchain Engineer',
careers_job2_desc: 'Develop and maintain smart contracts and on-chain data indexing. Solidity and Cosmos SDK experience required.',
careers_job3_title: 'Mobile Engineer',
careers_job3_desc: 'Build cross-platform Flutter apps with a focus on user experience and performance optimization.',
careers_job4_title: 'Security Engineer',
careers_job4_desc: 'Design platform security architecture, conduct penetration testing, and fix vulnerabilities to protect user assets and data.',
careers_job5_title: 'Data Analyst',
careers_job5_desc: 'Analyze trading data, model user behavior, and optimize risk control models to drive business growth.',
careers_job6_title: 'UI/UX Designer',
careers_job6_desc: 'Design visual interfaces and interaction experiences to create clean, intuitive fintech product interfaces.',
careers_apply: 'Apply Now',
// -- Blog --
blog_title: 'Blog',
blog_subtitle: 'Industry insights, product updates, and technical deep dives',
blog_p1_date: 'Mar 1, 2025',
blog_p1_title: 'Genex 2.0 Launch: New Trading Engine & User Experience',
blog_p1_excerpt: 'After months of refinement, Genex 2.0 brings a brand-new matching engine, more intuitive trading interface, and a more powerful AI recommendation system.',
blog_p2_date: 'Feb 15, 2025',
blog_p2_title: 'How Blockchain Secures Every Coupon Transaction',
blog_p2_excerpt: 'A deep dive into Genex\'s blockchain architecture and how smart contracts enable automated settlement and asset security.',
blog_p3_date: 'Feb 1, 2025',
blog_p3_title: 'Coupon Trading 101: From Discount Buying to Secondary Market',
blog_p3_excerpt: 'Everything you need to know about coupon trading concepts, strategies, and risk management to start your smart spending journey.',
blog_p4_date: 'Jan 20, 2025',
blog_p4_title: 'Success Story: How a Restaurant Chain Grew 300% with Genex',
blog_p4_excerpt: 'See how a leading restaurant brand leveraged Genex coupon issuance to achieve 300% monthly customer acquisition growth.',
blog_p5_date: 'Jan 10, 2025',
blog_p5_title: 'AI Assistant Launch: Smarter Trading Decisions',
blog_p5_excerpt: 'The Genex AI Assistant is live — analyzing market trends, recommending optimal trade timing, and serving as your personal trading advisor.',
blog_p6_date: 'Dec 25, 2024',
blog_p6_title: 'Genex Ecosystem: From Local to Global',
blog_p6_excerpt: 'Reflecting on Genex\'s journey and previewing our 2025 global strategy and product roadmap.',
// -- Press --
press_title: 'Press',
press_subtitle: 'The latest news from Genex',
press_p1_date: 'March 2025',
press_p1_title: 'Genex Raises Series A to Accelerate Global Expansion',
press_p1_desc: 'Genex announces the completion of its Series A funding round. The capital will be used for technology R&D, team expansion, and overseas market development, with plans to enter Southeast Asian and European markets within the next year.',
press_p2_date: 'January 2025',
press_p2_title: 'Genex Partners with Leading Consumer Brands',
press_p2_desc: 'The platform has established partnerships with over 200 brand merchants across dining, retail, and lifestyle services, offering users a richer selection of coupons.',
press_p3_date: 'November 2024',
press_p3_title: 'Genex Platform Officially Launches',
press_p3_desc: 'The Genex coupon finance trading platform officially launched, surpassing 10,000 registered users on day one. Powered by blockchain technology, the platform provides consumers and merchants with a secure, transparent coupon trading marketplace.',
// -- Help Center --
help_title: 'Help Center',
help_subtitle: 'Find answers to your questions quickly',
help_t1_title: 'How do I buy coupons?',
help_t1_desc: 'Browse the homepage or search for brands you like, select the coupon denomination and quantity, and complete your purchase using a supported payment method.',
help_t2_title: 'How do I trade on the secondary market?',
help_t2_desc: 'Go to the "Trade" page where you can list your coupons for sale or browse and purchase coupons listed by other users.',
help_t3_title: 'What payment methods are supported?',
help_t3_desc: 'The platform supports Alipay, WeChat Pay, bank cards, and platform balance as payment methods.',
help_t4_title: 'Is my money safe?',
help_t4_desc: 'The platform uses blockchain smart contract settlement. Funds are held in escrow by the contract, never passing through platform accounts, ensuring security and transparency.',
help_t5_title: 'How do I use my coupons?',
help_t5_desc: 'Open your coupon in the App or Mini Program and present the coupon code to the merchant or let them scan it for redemption.',
help_t6_title: 'What if I have a problem?',
help_t6_desc: 'You can submit a ticket through our Contact page or call our customer service hotline for immediate assistance.',
help_contact_hint: 'Can\'t find your answer?',
help_contact_link: 'Contact Support',
// -- Community --
community_title: 'Community',
community_subtitle: 'Join the Genex community and connect with like-minded people',
community_c1_title: 'WeChat Groups',
community_c1_desc: 'Join official WeChat groups for platform updates, trading strategy sharing, and exclusive benefits.',
community_c2_title: 'Twitter / X',
community_c2_desc: 'Follow @Genex for the latest product updates and industry news.',
community_c3_title: 'Telegram',
community_c3_desc: 'Join our Telegram channel for global community discussions and international market insights.',
community_c4_title: 'Developer Forum',
community_c4_desc: 'Technical discussions, API support, SDK integration help — build the ecosystem with fellow developers.',
community_c5_title: 'WeChat Official Account',
community_c5_desc: 'Follow "Genex" on WeChat for in-depth analysis articles and tutorials.',
community_c6_title: 'Online Events',
community_c6_desc: 'Regular AMAs, trading competitions, and airdrop events with generous rewards.',
// -- System Status --
status_title: 'System Status',
status_subtitle: 'Real-time monitoring of all service status',
status_all_ok: 'All Systems Operational',
status_s1_name: 'Trading Engine',
status_s1_status: 'Operational',
status_s2_name: 'User Service',
status_s2_status: 'Operational',
status_s3_name: 'Payment Gateway',
status_s3_status: 'Operational',
status_s4_name: 'Blockchain Network',
status_s4_status: 'Operational',
status_s5_name: 'API Service',
status_s5_status: 'Operational',
status_s6_name: 'Notification Service',
status_s6_status: 'Operational',
status_updated: 'Status auto-updates every 5 minutes',
// -- Download --
download_title: 'Download Genex',
download_subtitle: 'Start your smart spending journey anytime, anywhere',
download_ios_title: 'iOS App',
download_ios_desc: 'Supports iPhone and iPad with a smooth native experience',
download_ios_btn: 'Download on App Store',
download_android_title: 'Android App',
download_android_desc: 'Supports Android 8.0 and above',
download_android_btn: 'Get it on Google Play',
download_web_title: 'Web Version',
download_web_desc: 'No installation needed — access all features directly in your browser',
download_web_btn: 'Open Web App',
download_miniapp_title: 'WeChat Mini Program',
download_miniapp_desc: 'Search "Genex" in WeChat — lightweight and convenient',
download_miniapp_btn: 'Scan to Try',
};
export default enUS;

View File

@ -0,0 +1,33 @@
'use client';
import { createContext, useContext } from 'react';
import zhCN from './zh-CN';
import enUS from './en-US';
export type Locale = 'zh-CN' | 'en-US';
const locales: Record<Locale, Record<string, string>> = {
'zh-CN': zhCN,
'en-US': enUS,
};
export function t(key: string, locale: Locale = 'zh-CN'): string {
return locales[locale]?.[key] ?? locales['zh-CN']?.[key] ?? key;
}
export const LocaleContext = createContext<{
locale: Locale;
setLocale: (l: Locale) => void;
}>({
locale: 'zh-CN',
setLocale: () => {},
});
export function useLocale() {
return useContext(LocaleContext);
}
export function useT() {
const { locale } = useLocale();
return (key: string) => t(key, locale);
}

View File

@ -0,0 +1,375 @@
const zhCN: Record<string, string> = {
// -- Nav --
nav_home: '首页',
nav_about: '关于我们',
nav_features: '产品功能',
nav_solutions: '解决方案',
nav_developers: '开发者',
nav_contact: '联系我们',
nav_download: '下载 App',
nav_register: '立即注册',
// -- Hero --
hero_title: '让每一张券,都有价值',
hero_subtitle: '区块链驱动的券资产交易平台,折扣购券、自由交易、安全保障',
hero_cta_download: '下载 App',
hero_cta_register: '免费注册',
// -- Stats --
stats_volume: '累计交易额',
stats_users: '活跃用户',
stats_coupons: '发行券数',
stats_merchants: '合作商户',
stats_unit_yi: '亿',
stats_unit_wan: '万+',
stats_unit_ge: '+',
// -- Advantages --
adv_title: '为什么选择 Genex',
adv_discount_title: '折扣购券',
adv_discount_desc: '以低于面值的价格购买优惠券,每笔消费都能省钱',
adv_trade_title: '自由交易',
adv_trade_desc: '二级市场自由买卖,价格透明,随时变现',
adv_secure_title: '安全保障',
adv_secure_desc: '区块链智能合约清算,资金安全,隐私保护',
// -- Product --
prod_title: '一站式券交易体验',
prod_subtitle: '从购买到交易到使用,全流程智能管理',
prod_buy: '轻松购券',
prod_buy_desc: '浏览精选优惠券,一键折扣购买',
prod_trade: '灵活交易',
prod_trade_desc: '挂单出售、市价购买,像股票一样交易',
prod_use: '便捷使用',
prod_use_desc: '扫码核销,直接与商户结算',
prod_earn: '收益管理',
prod_earn_desc: '实时查看持仓收益,智能推荐交易时机',
// -- Trust --
trust_title: '值得信赖的平台',
trust_blockchain: '区块链技术',
trust_blockchain_desc: '所有交易上链存证,不可篡改',
trust_compliance: '合规运营',
trust_compliance_desc: '持牌经营,严格遵守金融监管法规',
trust_rating: '信用评级',
trust_rating_desc: '透明的商户信用评级体系,让选择更安心',
trust_insurance: '资金保障',
trust_insurance_desc: '引入保险机制,为每一笔交易保驾护航',
// -- CTA --
cta_title: '开启智慧消费新时代',
cta_subtitle: '立即下载 Genex App享受折扣购券的乐趣',
cta_ios: 'App Store',
cta_android: 'Google Play',
cta_miniapp: '微信小程序',
// -- Footer --
footer_slogan: '区块链驱动的券金融平台',
footer_product: '产品',
footer_company: '公司',
footer_support: '支持',
footer_legal: '法律',
footer_mobile_app: '移动 App',
footer_miniapp: '微信小程序',
footer_web_trade: 'Web 交易',
footer_api: 'API 文档',
footer_about: '关于我们',
footer_careers: '加入我们',
footer_blog: '博客',
footer_press: '媒体',
footer_help: '帮助中心',
footer_contact: '联系我们',
footer_community: '社区',
footer_status: '系统状态',
footer_terms: '用户协议',
footer_privacy: '隐私政策',
footer_cookie: 'Cookie 政策',
footer_risk: '风险提示',
footer_icp: '粤ICP备XXXXXXXX号',
footer_copyright: '© 2025 Genex. 保留所有权利。',
// -- About --
about_title: '关于 Genex',
about_subtitle: '让消费更有价值',
about_vision_title: '我们的愿景',
about_vision_desc: '构建一个开放、透明、高效的券资产交易生态系统,让每一张优惠券都能被发现、交易和实现价值。',
about_mission_title: '我们的使命',
about_mission_desc: '通过区块链技术打破信息壁垒,让消费者享受真正的优惠,让商户获得高效的获客渠道。',
about_value_title: '核心价值观',
about_value_1: '用户至上',
about_value_1_desc: '始终以用户需求为导向,提供极致的产品体验',
about_value_2: '透明公正',
about_value_2_desc: '所有交易公开透明,信用评级公正客观',
about_value_3: '技术创新',
about_value_3_desc: '持续投入前沿技术研发,引领行业变革',
about_value_4: '合规经营',
about_value_4_desc: '严格遵守法律法规,保障平台安全稳定',
about_milestone_title: '发展历程',
about_milestone_2025_q1: '项目启动,完成核心架构设计',
about_milestone_2025_q2: '智能合约开发完成,测试网上线',
about_milestone_2025_q3: '移动 App 与管理后台发布',
about_milestone_2025_q4: '主网上线,首批商户入驻',
about_milestone_2026_q1: '二级市场交易功能上线',
about_milestone_2026_q2: '用户突破 10 万,生态持续扩展',
about_partners_title: '合作伙伴',
// -- Features --
features_title: '强大的产品功能',
features_subtitle: '从券的发行到交易到核销,全链路覆盖',
feat_buy_title: '智能购券',
feat_buy_desc: '浏览海量优惠券AI 智能推荐最适合你的折扣券。支持分类筛选、价格排序、信用评级过滤。',
feat_market_title: '二级市场',
feat_market_desc: '像交易股票一样交易优惠券。限价单、市价单、挂单交易,价格实时更新,撮合引擎毫秒级响应。',
feat_contract_title: '合约清算',
feat_contract_desc: '区块链智能合约自动清算,消费者直接与商户结算,平台不触碰资金,安全透明。',
feat_rating_title: '信用评级',
feat_rating_desc: '基于履约率、用户评价、经营年限等多维度指标,为每个发行方建立透明的信用画像。',
feat_multi_title: '多端同步',
feat_multi_desc: 'iOS App、Android App、微信小程序、Web 端全覆盖,数据实时同步,随时随地交易。',
feat_ai_title: 'AI 助手',
feat_ai_desc: '内置智能 AI 助手,帮你分析市场趋势、推荐最佳买入时机、自动预警风险。',
// -- Solutions --
solutions_title: '解决方案',
solutions_subtitle: '无论你是消费者还是商户Genex 都能为你创造价值',
sol_consumer_title: '消费者方案',
sol_consumer_subtitle: '更聪明的消费方式',
sol_c_save_title: '省钱购物',
sol_c_save_desc: '以 85-95 折购买餐饮、零售、生活服务优惠券,日常消费立省 5%-15%',
sol_c_invest_title: '券投资',
sol_c_invest_desc: '低买高卖热门券,把握市场波动获取收益',
sol_c_gift_title: '礼品券',
sol_c_gift_desc: '购买精选品牌券作为礼物赠送,实惠又体面',
sol_merchant_title: '商户方案',
sol_merchant_subtitle: '高效的获客工具',
sol_m_acquire_title: '精准获客',
sol_m_acquire_desc: '通过发行优惠券吸引目标消费者,获客成本低于传统广告',
sol_m_finance_title: '短期融资',
sol_m_finance_desc: '券的预售模式实现短期融资,零利息,灵活便捷',
sol_m_loyalty_title: '会员运营',
sol_m_loyalty_desc: '通过券的分发和交易数据,精准分析用户画像,提升复购率',
// -- Developers --
dev_title: '开发者中心',
dev_subtitle: '接入 Genex 生态,构建你的应用',
dev_api_title: 'RESTful API',
dev_api_desc: '完整的 REST API 文档,覆盖券发行、交易、清算全流程',
dev_sdk_title: 'SDK',
dev_sdk_desc: '提供 JavaScript、Go、Dart 多语言 SDK快速集成',
dev_explorer_title: '区块链浏览器',
dev_explorer_desc: '查询链上交易记录、合约状态、区块信息',
dev_sandbox_title: '沙箱环境',
dev_sandbox_desc: '完整的测试环境,免费体验所有 API 功能',
dev_quickstart: '快速开始',
dev_doc_link: '查看完整文档',
dev_explorer_link: '打开浏览器',
// -- Contact --
contact_title: '联系我们',
contact_subtitle: '我们很乐意听取您的意见和建议',
contact_form_name: '您的姓名',
contact_form_email: '电子邮箱',
contact_form_subject: '主题',
contact_form_message: '留言内容',
contact_form_submit: '发送消息',
contact_form_success: '消息已发送,我们会尽快回复您!',
contact_office_title: '办公地址',
contact_office_addr: '深圳市南山区科技园',
contact_email_title: '电子邮箱',
contact_email_addr: 'contact@gogenex.com',
contact_social_title: '关注我们',
// -- Terms of Service --
terms_title: '用户协议',
terms_updated: '最后更新2025 年 1 月 1 日',
terms_s1_title: '1. 协议接受',
terms_s1_p1: '欢迎使用 Genex 平台(以下简称"平台")。在使用平台提供的任何服务之前,请您仔细阅读本用户协议(以下简称"本协议")。您注册、登录或使用本平台即表示您已阅读、理解并同意受本协议的约束。',
terms_s1_p2: '如果您不同意本协议的任何条款,请勿使用本平台服务。',
terms_s2_title: '2. 账户注册与安全',
terms_s2_p1: '您在注册账户时必须提供真实、准确、完整的个人信息,并在信息变更时及时更新。您有责任:',
terms_s2_li1: '妥善保管您的账户登录凭证,不得将账户转让或借予他人',
terms_s2_li2: '对通过您的账户进行的所有操作承担全部责任',
terms_s2_li3: '发现账户异常或安全隐患时,立即通知平台',
terms_s3_title: '3. 服务内容',
terms_s3_p1: '本平台为用户提供券资产的浏览、购买、出售、交易和核销等服务。平台仅作为交易撮合方,不参与券的发行和兑付。',
terms_s3_p2: '平台上展示的券产品信息由发行方提供,平台会进行合理审核,但不对信息的绝对准确性做出保证。',
terms_s4_title: '4. 交易规则',
terms_s4_p1: '用户在平台上进行的所有交易均须遵守平台公布的交易规则。交易一旦成交,不可撤销。用户应充分了解交易风险,审慎决策。',
terms_s5_title: '5. 知识产权',
terms_s5_p1: '本平台的所有内容,包括但不限于文字、图片、软件、商标、界面设计等,均受知识产权法律保护。未经平台书面授权,任何人不得复制、修改、传播或以其他方式使用。',
terms_s6_title: '6. 免责声明',
terms_s6_p1: '在法律允许的最大范围内,平台不对因不可抗力、第三方原因、用户自身原因导致的损失承担责任。平台可能因系统维护、升级等原因暂停服务,会尽量提前通知用户。',
// -- Privacy Policy --
privacy_title: '隐私政策',
privacy_updated: '最后更新2025 年 1 月 1 日',
privacy_s1_title: '1. 引言',
privacy_s1_p1: 'Genex 重视您的隐私保护。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。使用本平台即表示您同意本政策所述的数据处理方式。',
privacy_s2_title: '2. 信息收集',
privacy_s2_p1: '我们可能收集以下类型的信息:',
privacy_s2_li1: '身份信息:姓名、手机号码、电子邮箱、身份证信息(实名认证时)',
privacy_s2_li2: '交易信息:购买记录、交易历史、钱包余额',
privacy_s2_li3: '设备信息:设备型号、操作系统版本、唯一设备标识符',
privacy_s2_li4: '使用数据:浏览记录、搜索关键词、功能使用频率',
privacy_s3_title: '3. 信息使用',
privacy_s3_p1: '我们使用收集的信息来提供、维护和改进平台服务,进行身份验证和安全防护,向您发送交易通知和平台更新,以及进行数据分析以优化用户体验。',
privacy_s4_title: '4. 信息共享',
privacy_s4_p1: '未经您的同意,我们不会向第三方出售或出租您的个人信息。在以下情况下,我们可能会共享您的信息:与合规要求相关的监管机构、为完成交易而必要的对手方、经您明确授权的第三方合作伙伴。',
privacy_s5_title: '5. 数据安全',
privacy_s5_p1: '我们采用行业标准的加密技术和安全措施保护您的个人信息,包括但不限于 SSL 加密传输、数据加密存储、访问控制和安全审计。但请注意,没有任何互联网传输方式是完全安全的。',
privacy_s6_title: '6. 您的权利',
privacy_s6_p1: '您有权访问、更正或删除您的个人信息。您可以通过账户设置或联系客服行使这些权利。如需删除账户及相关数据,请联系我们的客服团队。',
// -- Cookie Policy --
cookie_title: 'Cookie 政策',
cookie_updated: '最后更新2025 年 1 月 1 日',
cookie_s1_title: '1. 什么是 Cookie',
cookie_s1_p1: 'Cookie 是网站在您浏览时存储在您设备上的小型文本文件。它们帮助网站记住您的偏好设置,提供更好的浏览体验。',
cookie_s2_title: '2. 我们使用的 Cookie 类型',
cookie_s2_p1: '我们使用以下几类 Cookie 来提升您的使用体验:',
cookie_s2_h3_1: '必要性 Cookie',
cookie_s2_p2: '用于维持您的登录状态、语言偏好和安全验证。这些 Cookie 是平台正常运行所必需的。',
cookie_s2_h3_2: '分析性 Cookie',
cookie_s2_p3: '帮助我们了解用户如何使用平台,以便改进产品体验。这些数据是匿名聚合的。',
cookie_s2_h3_3: '功能性 Cookie',
cookie_s2_p4: '记住您的个性化设置,如语言选择、主题偏好等,为您提供定制化的使用体验。',
cookie_s3_title: '3. 管理 Cookie',
cookie_s3_p1: '您可以通过浏览器设置管理或删除 Cookie。请注意禁用某些 Cookie 可能影响平台部分功能的正常使用。',
// -- Risk Disclosure --
risk_title: '风险提示',
risk_updated: '最后更新2025 年 1 月 1 日',
risk_s1_title: '1. 一般风险提示',
risk_s1_p1: '券资产交易存在固有的市场风险。券的价格可能因市场供需、发行方经营状况、宏观经济环境等因素而发生波动。您在使用本平台进行交易之前,应充分了解相关风险。',
risk_s2_title: '2. 具体风险因素',
risk_s2_li1: '市场风险:券的二级市场价格可能低于您的买入价格,导致亏损',
risk_s2_li2: '信用风险:券的发行方可能因经营不善无法兑付,导致券失去价值',
risk_s2_li3: '流动性风险:部分券可能交易量较低,难以在短时间内以理想价格成交',
risk_s2_li4: '技术风险:区块链网络拥堵、系统故障等技术因素可能影响交易执行',
risk_s3_title: '3. 投资建议',
risk_s3_p1: '请根据自身财务状况和风险承受能力进行投资决策。不要投入超出自己承受能力的资金。过去的交易表现不代表未来的收益。',
risk_s4_title: '4. 免责声明',
risk_s4_p1: '本平台不提供任何投资建议。平台上的所有信息仅供参考,不构成购买或出售任何券产品的推荐或要约。用户应自行判断并承担交易结果。',
// -- Careers --
careers_title: '加入我们',
careers_subtitle: '与优秀的人一起,做有意义的事',
careers_job1_title: '高级全栈工程师',
careers_job1_desc: '负责平台核心功能开发,要求精通 TypeScript、React、Node.js有金融或区块链项目经验优先。',
careers_job2_title: '区块链工程师',
careers_job2_desc: '负责智能合约开发与维护、链上数据索引,要求熟悉 Solidity、Cosmos SDK。',
careers_job3_title: '移动端工程师',
careers_job3_desc: '负责 Flutter 跨平台 App 开发,追求极致的用户体验和性能优化。',
careers_job4_title: '安全工程师',
careers_job4_desc: '负责平台安全架构设计、渗透测试、漏洞修复,保障用户资产和数据安全。',
careers_job5_title: '数据分析师',
careers_job5_desc: '负责交易数据分析、用户行为建模、风控模型优化,驱动业务增长。',
careers_job6_title: 'UI/UX 设计师',
careers_job6_desc: '负责平台产品的视觉设计与交互体验,打造简洁、直觉的金融产品界面。',
careers_apply: '投递简历',
// -- Blog --
blog_title: '博客',
blog_subtitle: '行业洞察、产品动态、技术分享',
blog_p1_date: '2025-03-01',
blog_p1_title: 'Genex 2.0 正式上线:全新交易引擎与用户体验',
blog_p1_excerpt: '经过数月的精心打磨Genex 2.0 带来了全新的撮合引擎、更直观的交易界面和更强大的 AI 推荐系统。',
blog_p2_date: '2025-02-15',
blog_p2_title: '区块链如何保障您的每一笔券交易',
blog_p2_excerpt: '深入解析 Genex 底层区块链架构,了解智能合约如何实现自动清算与资产安全保障。',
blog_p3_date: '2025-02-01',
blog_p3_title: '券资产投资入门指南:从折扣购券到二级市场交易',
blog_p3_excerpt: '一篇文章带你了解券交易的基本概念、策略和风险管理,开启你的智慧消费之旅。',
blog_p4_date: '2025-01-20',
blog_p4_title: '商户成功案例:某连锁餐饮品牌的获客之道',
blog_p4_excerpt: '看某知名餐饮品牌如何通过 Genex 平台发行优惠券,实现月均获客量增长 300%。',
blog_p5_date: '2025-01-10',
blog_p5_title: 'AI 助手上线:让交易决策更智能',
blog_p5_excerpt: 'Genex AI 助手正式上线,它能帮你分析市场趋势、推荐最佳交易时机,做你的专属交易顾问。',
blog_p6_date: '2024-12-25',
blog_p6_title: 'Genex 生态版图:从国内走向全球',
blog_p6_excerpt: '回顾 Genex 的发展历程,展望 2025 年的全球化战略布局与产品路线图。',
// -- Press --
press_title: '媒体资讯',
press_subtitle: '了解 Genex 的最新动态',
press_p1_date: '2025 年 3 月',
press_p1_title: 'Genex 获得 A 轮融资,加速全球化布局',
press_p1_desc: 'Genex 宣布完成 A 轮融资,本轮融资将用于技术研发、团队扩张和海外市场拓展。公司表示将在未来一年内进入东南亚和欧洲市场。',
press_p2_date: '2025 年 1 月',
press_p2_title: 'Genex 与多家知名品牌达成战略合作',
press_p2_desc: '平台已与超过 200 家品牌商户建立合作关系,覆盖餐饮、零售、生活服务等多个领域,为用户提供更丰富的优惠券选择。',
press_p3_date: '2024 年 11 月',
press_p3_title: 'Genex 平台正式上线,开启券金融新时代',
press_p3_desc: 'Genex 券金融交易平台正式上线运营,首日注册用户突破万人。平台以区块链技术为核心,为消费者和商户搭建了安全、透明的券交易市场。',
// -- Help Center --
help_title: '帮助中心',
help_subtitle: '快速找到您需要的答案',
help_t1_title: '如何购买优惠券?',
help_t1_desc: '浏览首页或搜索您感兴趣的品牌,选择券面额和数量,使用支持的支付方式完成购买即可。',
help_t2_title: '如何在二级市场交易?',
help_t2_desc: '进入"交易"页面,您可以挂单出售持有的券,也可以浏览市场上其他用户出售的券并购买。',
help_t3_title: '支持哪些支付方式?',
help_t3_desc: '平台支持支付宝、微信支付、银行卡和平台余额等多种支付方式。',
help_t4_title: '我的资金安全吗?',
help_t4_desc: '平台采用区块链智能合约清算,资金由合约托管,不经过平台账户,确保安全透明。',
help_t5_title: '如何使用优惠券?',
help_t5_desc: '在 App 或小程序中打开您持有的券,到店出示券码或让商户扫描即可核销使用。',
help_t6_title: '遇到问题怎么办?',
help_t6_desc: '您可以通过联系我们页面提交工单,或拨打客服热线获取即时帮助。',
help_contact_hint: '没有找到答案?',
help_contact_link: '联系客服',
// -- Community --
community_title: '社区',
community_subtitle: '加入 Genex 社区,与志同道合的伙伴交流',
community_c1_title: '微信社群',
community_c1_desc: '加入官方微信群,第一时间获取平台动态、交易策略分享和专属福利。',
community_c2_title: 'Twitter / X',
community_c2_desc: '关注 @Genex 官方账号,获取最新产品更新和行业资讯。',
community_c3_title: 'Telegram',
community_c3_desc: '加入 Telegram 频道,参与全球社区讨论,了解海外市场动态。',
community_c4_title: '开发者论坛',
community_c4_desc: '技术交流、API 使用答疑、SDK 集成讨论,与其他开发者共建生态。',
community_c5_title: '微信公众号',
community_c5_desc: '关注「Genex 券金融」公众号,阅读深度分析文章和使用教程。',
community_c6_title: '线上活动',
community_c6_desc: '定期举办 AMA、交易大赛、空投活动丰厚奖励等你来拿。',
// -- System Status --
status_title: '系统状态',
status_subtitle: '实时监控所有服务运行状态',
status_all_ok: '所有系统运行正常',
status_s1_name: '交易撮合引擎',
status_s1_status: '正常',
status_s2_name: '用户服务',
status_s2_status: '正常',
status_s3_name: '支付网关',
status_s3_status: '正常',
status_s4_name: '区块链网络',
status_s4_status: '正常',
status_s5_name: 'API 服务',
status_s5_status: '正常',
status_s6_name: '通知服务',
status_s6_status: '正常',
status_updated: '状态每 5 分钟自动更新',
// -- Download --
download_title: '下载 Genex',
download_subtitle: '随时随地,开始您的智慧消费之旅',
download_ios_title: 'iOS App',
download_ios_desc: '支持 iPhone 和 iPad流畅的原生体验',
download_ios_btn: 'App Store 下载',
download_android_title: 'Android App',
download_android_desc: '支持 Android 8.0 及以上版本',
download_android_btn: 'Google Play 下载',
download_web_title: 'Web 版',
download_web_desc: '无需安装,浏览器直接使用全部功能',
download_web_btn: '打开 Web 版',
download_miniapp_title: '微信小程序',
download_miniapp_desc: '微信搜索"Genex"即可使用,轻量便捷',
download_miniapp_btn: '扫码体验',
};
export default zhCN;

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
阿里云实人认证 (CloudAuth) 管理工具
功能: 认证场景管理发起认证查询结果套餐余额查询
依赖: pip install alibabacloud-cloudauth20190307 alibabacloud-bssopenapi20171214
"""
import argparse
import json
import sys
import os
import uuid
def get_client():
from alibabacloud_cloudauth20190307.client import Client
from alibabacloud_tea_openapi.models import Config
ak_id = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_ID', '')
ak_secret = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_SECRET', '')
endpoint = os.environ.get('ALIYUN_KYC_ENDPOINT', 'cloudauth.aliyuncs.com')
if not ak_id or not ak_secret:
print('ERROR: Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET')
sys.exit(1)
config = Config(
access_key_id=ak_id,
access_key_secret=ak_secret,
endpoint=endpoint,
)
return Client(config)
# ──────────────── 认证场景 ────────────────
def describe_verify_setting(args):
"""查询认证场景配置 (通过探测 API 检查服务状态)"""
client = get_client()
from alibabacloud_cloudauth20190307.models import InitFaceVerifyRequest
scene_id = args.scene_id or os.environ.get('ALIYUN_KYC_SCENE_ID', '')
try:
# Probe service via InitFaceVerify with dummy data
req = InitFaceVerifyRequest(
scene_id=int(scene_id) if scene_id else 0,
outer_order_no=str(uuid.uuid4()),
product_code='ID_PRO',
cert_type='IDENTITY_CARD',
cert_name='probe',
cert_no='000000000000000000',
)
resp = client.init_face_verify(req)
body = resp.body
print(f'\n📋 实人认证服务状态\n')
print(f' 场景ID: {scene_id or "(未设置)"}')
print(f' 产品码: ID_PRO (人脸活体检测)')
print(f' 请求ID: {body.request_id}')
print(f' 状态: ✅ 服务可用 (权限正常)')
if body.code and body.code != '200':
print(f' 探测响应: {body.code} - {body.message or ""}')
except Exception as e:
err = str(e)
if 'NoPermission' in err or '403' in err:
print(f'\n❌ 权限不足: RAM 子账号缺少 CloudAuth 权限')
print(f' 请添加 AliyunYundunCloudAuthFullAccess 策略')
else:
print(f'ERROR: {e}')
# ──────────────── 发起认证 ────────────────
def init_face_verify(args):
"""发起人脸活体检测认证"""
client = get_client()
from alibabacloud_cloudauth20190307.models import InitFaceVerifyRequest
scene_id = args.scene_id or os.environ.get('ALIYUN_KYC_SCENE_ID', '')
outer_order_no = args.order_no or str(uuid.uuid4())
req = InitFaceVerifyRequest(
scene_id=int(scene_id),
outer_order_no=outer_order_no,
product_code='ID_PRO',
cert_type='IDENTITY_CARD',
cert_name=args.name,
cert_no=args.id_number,
)
try:
resp = client.init_face_verify(req)
body = resp.body
if body.code == '200' or body.code == 200:
result = body.result_object
print(f'✅ 认证已发起')
print(f' CertifyId: {result.certify_id}')
print(f' CertifyUrl: {result.certify_url}')
print(f' OrderNo: {outer_order_no}')
else:
print(f'ERROR: {body.code} - {body.message}')
except Exception as e:
print(f'ERROR: {e}')
def describe_face_verify(args):
"""查询认证结果"""
client = get_client()
from alibabacloud_cloudauth20190307.models import DescribeFaceVerifyRequest
req = DescribeFaceVerifyRequest(
scene_id=int(args.scene_id or os.environ.get('ALIYUN_KYC_SCENE_ID', '')),
certify_id=args.certify_id,
)
try:
resp = client.describe_face_verify(req)
body = resp.body
if body.code == '200' or body.code == 200:
result = body.result_object
passed = result.passed == 'T'
print(f'\n📋 认证结果')
print(f' CertifyId: {args.certify_id}')
print(f' 通过: {"✅ 是" if passed else "❌ 否"}')
print(f' SubCode: {result.sub_code}')
if result.material_info:
print(f' 材料信息: {result.material_info}')
else:
print(f'ERROR: {body.code} - {body.message}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 身份二要素核验 ────────────────
def verify_identity(args):
"""身份证二要素核验 (姓名+身份证号)"""
client = get_client()
from alibabacloud_cloudauth20190307.models import Id2MetaVerifyRequest
req = Id2MetaVerifyRequest(
param_type='normal',
user_name=args.name,
identity_card_number=args.id_number,
)
try:
resp = client.id_2meta_verify(req)
body = resp.body
if body.code == '200' or body.code == 200:
result = body.result_object
bizCode = result.biz_code if result else 'UNKNOWN'
passed = bizCode == '1'
print(f'\n📋 身份二要素核验')
print(f' 姓名: {args.name}')
print(f' 身份证: {args.id_number[:6]}****{args.id_number[-4:]}')
print(f' 结果: {"✅ 一致" if passed else "❌ 不一致"}')
print(f' BizCode: {bizCode}')
else:
print(f'ERROR: {body.code} - {body.message}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 套餐余额查询 ────────────────
def query_quota(args):
"""查询实人认证套餐余额 (通过 BSS API)"""
from alibabacloud_bssopenapi20171214.client import Client as BssClient
from alibabacloud_bssopenapi20171214.models import QueryResourcePackageInstancesRequest
from alibabacloud_tea_openapi.models import Config
ak_id = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_ID', '')
ak_secret = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_SECRET', '')
config = Config(
access_key_id=ak_id,
access_key_secret=ak_secret,
endpoint='business.aliyuncs.com',
)
client = BssClient(config)
try:
req = QueryResourcePackageInstancesRequest(page_num=1, page_size=100)
resp = client.query_resource_package_instances(req)
body = resp.body
instances = body.data.instances.instance if body.data and body.data.instances else []
# Filter cloudauth packages
auth_pkgs = [i for i in instances if 'cloudauth' in (i.to_map().get('CommodityCode', '') or '').lower()]
if not auth_pkgs:
print('\n📋 未找到实人认证相关套餐')
return
print(f'\n📋 实人认证套餐余额 (共 {len(auth_pkgs)} 个)\n')
print(f'{"套餐名称":<30} {"总量":<10} {"剩余":<10} {"到期日":<22} {"状态"}')
print('-' * 90)
for pkg in auth_pkgs:
m = pkg.to_map()
name = m.get('Remark', '') or m.get('PackageType', '')
total = m.get('TotalAmount', '0')
remain = m.get('RemainingAmount', '0')
unit = m.get('TotalAmountUnit', '')
expiry = (m.get('ExpiryTime', '') or '')[:10]
status = m.get('Status', '')
status_str = '✅ 可用' if status == 'Available' else f'{status}'
print(f'{name:<30} {total}{unit:<8} {remain}{unit:<8} {expiry:<22} {status_str}')
except Exception as e:
err = str(e)
if 'NotAuthorized' in err:
print(f'\n❌ 权限不足: 需要 AliyunBSSReadOnlyAccess 策略')
else:
print(f'ERROR: {e}')
# ──────────────── CLI ────────────────
def main():
parser = argparse.ArgumentParser(description='阿里云实人认证管理工具')
sub = parser.add_subparsers(dest='command')
# 场景查询
p = sub.add_parser('list-scenes', help='查询认证场景配置')
p.add_argument('--scene-id', help='场景ID (留空查全部)')
# 发起人脸认证
p = sub.add_parser('init-face', help='发起人脸活体检测')
p.add_argument('--name', required=True, help='真实姓名')
p.add_argument('--id-number', required=True, help='身份证号')
p.add_argument('--scene-id', help='场景ID (默认取环境变量)')
p.add_argument('--order-no', help='业务订单号 (默认自动生成)')
# 查询认证结果
p = sub.add_parser('query-face', help='查询人脸认证结果')
p.add_argument('--certify-id', required=True, help='认证ID')
p.add_argument('--scene-id', help='场景ID')
# 套餐余额
p = sub.add_parser('quota', help='查询套餐余额')
# 身份二要素核验
p = sub.add_parser('verify-id', help='身份证二要素核验')
p.add_argument('--name', required=True, help='真实姓名')
p.add_argument('--id-number', required=True, help='身份证号')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
cmds = {
'list-scenes': describe_verify_setting,
'init-face': init_face_verify,
'query-face': describe_face_verify,
'verify-id': verify_identity,
'quota': query_quota,
}
cmds[args.command](args)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
阿里云域名管理工具
功能: 域名列表详情续费转出过户信息模板管理
依赖: pip install alibabacloud-domain20180129
"""
import argparse
import json
import sys
import os
def get_client():
from alibabacloud_domain20180129.client import Client
from alibabacloud_tea_openapi.models import Config
ak_id = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_ID', '')
ak_secret = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_SECRET', '')
if not ak_id or not ak_secret:
print('ERROR: Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET')
sys.exit(1)
config = Config(
access_key_id=ak_id,
access_key_secret=ak_secret,
endpoint='domain.aliyuncs.com',
)
return Client(config)
# ──────────────── 域名列表 ────────────────
def list_domains(args):
client = get_client()
from alibabacloud_domain20180129.models import QueryDomainListRequest
req = QueryDomainListRequest(
page_num=args.page,
page_size=args.size,
)
try:
resp = client.query_domain_list(req)
body = resp.body
domains = body.data.domain if body.data and body.data.domain else []
print(f'\n📋 域名列表 (共 {body.total_item_num} 个)\n')
print(f'{"域名":<30} {"到期日":<14} {"注册日":<14} {"状态"}')
print('-' * 75)
for d in domains:
exp = d.expiration_date[:10] if d.expiration_date else '-'
reg = d.registration_date[:10] if d.registration_date else '-'
# 域名状态
status = '正常'
if hasattr(d, 'domain_status') and d.domain_status:
status = d.domain_status
print(f'{d.domain_name:<30} {exp:<14} {reg:<14} {status}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 域名详情 ────────────────
def domain_info(args):
client = get_client()
from alibabacloud_domain20180129.models import QueryDomainByDomainNameRequest
req = QueryDomainByDomainNameRequest(domain_name=args.domain)
try:
resp = client.query_domain_by_domain_name(req)
body = resp.body
print(f'\n📋 域名详情: {args.domain}\n')
print(f' 注册商: {body.registrant_organization or body.registrant_name or "-"}')
print(f' 注册日: {body.registration_date or "-"}')
print(f' 到期日: {body.expiration_date or "-"}')
print(f' DNS: {", ".join(body.dns_list.dns) if body.dns_list else "-"}')
print(f' 状态: {body.domain_status or "-"}')
print(f' 实名认证: {"✅ 已认证" if body.zh_registrant_organization else "❌ 未认证"}')
print(f' 转移锁: {"🔒 已锁定" if body.transfer_out_status else "🔓 未锁定"}')
if body.email:
print(f' 联系邮箱: {body.email}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 域名续费 ────────────────
def renew_domain(args):
client = get_client()
from alibabacloud_domain20180129.models import SaveSingleTaskForCreatingOrderRenewRequest
req = SaveSingleTaskForCreatingOrderRenewRequest(
domain_name=args.domain,
subscription_duration=args.years,
current_expiration_date=0, # will be auto-detected
)
try:
resp = client.save_single_task_for_creating_order_renew(req)
body = resp.body
print(f'✅ 续费任务已提交: {args.domain} +{args.years}')
print(f' TaskNo: {body.task_no}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 域名转出 ────────────────
def transfer_out(args):
"""获取域名转移密码"""
client = get_client()
from alibabacloud_domain20180129.models import TransferOutDomainRequest
req = TransferOutDomainRequest(domain_name=args.domain)
try:
resp = client.transfer_out_domain(req)
body = resp.body
print(f'✅ 域名转出已发起: {args.domain}')
print(f' 转移密码将发送至域名联系人邮箱')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 信息模板管理 ────────────────
def list_templates(args):
"""查询信息模板"""
client = get_client()
from alibabacloud_domain20180129.models import QueryRegistrantProfilesRequest
req = QueryRegistrantProfilesRequest(
page_num=args.page,
page_size=args.size,
)
try:
resp = client.query_registrant_profiles(req)
body = resp.body
profiles = body.registrant_profiles.registrant_profile if body.registrant_profiles else []
print(f'\n📋 信息模板列表 (共 {body.total_item_num} 个)\n')
print(f'{"模板ID":<12} {"联系人":<20} {"实名认证":<10} {"邮箱":<30} {"类型"}')
print('-' * 90)
for p in profiles:
verified = '✅ 已认证' if p.email_verification_status == 1 else '❌ 未认证'
rtype = '企业' if p.registrant_type == 'e' else '个人'
name = p.zh_registrant_organization or p.registrant_name or '-'
print(f'{p.registrant_profile_id:<12} {name:<20} {verified:<10} {p.email or "-":<30} {rtype}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── 任务查询 ────────────────
def query_task(args):
"""查询任务详情"""
client = get_client()
from alibabacloud_domain20180129.models import QueryTaskDetailListRequest
req = QueryTaskDetailListRequest(
task_no=args.task_no,
page_num=1,
page_size=20,
)
try:
resp = client.query_task_detail_list(req)
body = resp.body
items = body.data.task_detail if body.data else []
print(f'\n📋 任务详情: {args.task_no}\n')
for item in items:
print(f' 域名: {item.domain_name}')
print(f' 状态: {item.task_status}')
print(f' 结果: {item.task_result or "-"}')
except Exception as e:
print(f'ERROR: {e}')
# ──────────────── CLI ────────────────
def main():
parser = argparse.ArgumentParser(description='阿里云域名管理工具')
sub = parser.add_subparsers(dest='command')
# 域名列表
p = sub.add_parser('list', help='列出所有域名')
p.add_argument('--page', type=int, default=1)
p.add_argument('--size', type=int, default=20)
# 域名详情
p = sub.add_parser('info', help='查询域名详情')
p.add_argument('--domain', required=True, help='域名')
# 续费
p = sub.add_parser('renew', help='域名续费')
p.add_argument('--domain', required=True, help='域名')
p.add_argument('--years', type=int, default=1, help='续费年数')
# 转出
p = sub.add_parser('transfer-out', help='域名转出 (获取转移密码)')
p.add_argument('--domain', required=True, help='域名')
# 信息模板
p = sub.add_parser('list-templates', help='查询信息模板')
p.add_argument('--page', type=int, default=1)
p.add_argument('--size', type=int, default=20)
# 任务查询
p = sub.add_parser('query-task', help='查询任务状态')
p.add_argument('--task-no', required=True, help='任务编号')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
cmds = {
'list': list_domains,
'info': domain_info,
'renew': renew_domain,
'transfer-out': transfer_out,
'list-templates': list_templates,
'query-task': query_task,
}
cmds[args.command](args)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""
阿里云 SMS 短信管理工具
功能: 签名管理模板管理发送记录查询发送测试
依赖: pip install alibabacloud-dysmsapi20170525
"""
import argparse
import json
import sys
import os
from datetime import datetime, timedelta
def get_client():
from alibabacloud_dysmsapi20170525.client import Client
from alibabacloud_tea_openapi.models import Config
ak_id = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_ID', '')
ak_secret = os.environ.get('ALIBABA_CLOUD_ACCESS_KEY_SECRET', '')
if not ak_id or not ak_secret:
print('ERROR: Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET')
sys.exit(1)
config = Config(
access_key_id=ak_id,
access_key_secret=ak_secret,
endpoint='dysmsapi.aliyuncs.com',
)
return Client(config)
# ──────────────── 签名管理 ────────────────
def list_signs(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import QuerySmsSignListRequest
req = QuerySmsSignListRequest(page_index=args.page, page_size=args.size)
resp = client.query_sms_sign_list(req)
body = resp.body
if body.code != 'OK':
print(f'ERROR: {body.code} - {body.message}')
return
signs = body.sms_sign_list or []
print(f'\n📋 SMS 签名列表 (共 {len(signs)} 个)\n')
print(f'{"签名名称":<20} {"审核状态":<14} {"创建日期":<22} {"业务类型"}')
print('-' * 80)
status_map = {
'AUDIT_STATE_INIT': '审核中',
'AUDIT_STATE_PASS': '✅ 已通过',
'AUDIT_STATE_NOT_PASS': '❌ 被驳回',
'AUDIT_STATE_CANCEL': '已取消',
}
for s in signs:
st = status_map.get(s.audit_status, str(s.audit_status))
biz = s.business_type or ''
print(f'{s.sign_name:<20} {st:<14} {s.create_date:<22} {biz}')
def create_sign(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import AddSmsSignRequest
req = AddSmsSignRequest(
sign_name=args.name,
sign_source=args.source,
remark=args.remark,
)
resp = client.add_sms_sign(req)
body = resp.body
if body.code == 'OK':
print(f'✅ 签名 "{args.name}" 提交成功,等待审核')
else:
print(f'ERROR: {body.code} - {body.message}')
def delete_sign(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import DeleteSmsSignRequest
req = DeleteSmsSignRequest(sign_name=args.name)
resp = client.delete_sms_sign(req)
body = resp.body
if body.code == 'OK':
print(f'✅ 签名 "{args.name}" 已删除')
else:
print(f'ERROR: {body.code} - {body.message}')
# ──────────────── 模板管理 ────────────────
def list_templates(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import QuerySmsTemplateListRequest
req = QuerySmsTemplateListRequest(page_index=args.page, page_size=args.size)
resp = client.query_sms_template_list(req)
body = resp.body
if body.code != 'OK':
print(f'ERROR: {body.code} - {body.message}')
return
templates = body.sms_template_list or []
print(f'\n📋 SMS 模板列表 (共 {len(templates)} 个)\n')
print(f'{"模板Code":<20} {"模板名称":<20} {"类型":<8} {"审核状态":<14} {"内容"}')
print('-' * 100)
status_map = {
'AUDIT_STATE_INIT': '审核中',
'AUDIT_STATE_PASS': '✅ 通过',
'AUDIT_STATE_NOT_PASS': '❌ 驳回',
'AUDIT_STATE_CANCEL': '已取消',
}
type_map = {0: '验证码', 1: '通知', 2: '推广', 3: '国际', 6: '数字短信', 7: '其他'}
for t in templates:
st = status_map.get(t.audit_status, str(t.audit_status))
tp = type_map.get(t.template_type, str(t.template_type))
content = (t.template_content or '')[:40]
print(f'{t.template_code:<20} {t.template_name:<20} {tp:<8} {st:<14} {content}')
def create_template(args):
client = get_client()
# Use new CreateSmsTemplate API (AddSmsTemplate is deprecated)
from alibabacloud_dysmsapi20170525.models import CreateSmsTemplateRequest
sign_name = getattr(args, 'sign', None) or os.environ.get('ALIYUN_SMS_SIGN_NAME', '')
rule = getattr(args, 'rule', None) or ''
req = CreateSmsTemplateRequest(
template_type=args.type,
template_name=args.name,
template_content=args.content,
remark=args.remark,
related_sign_name=sign_name,
template_rule=rule if rule else None,
)
resp = client.create_sms_template(req)
body = resp.body
if body.code == 'OK':
code = getattr(body, 'template_code', None) or getattr(body, 'order_id', 'N/A')
print(f'✅ 模板 "{args.name}" 提交成功 (Code/Order: {code}),等待审核')
else:
print(f'ERROR: {body.code} - {body.message}')
def delete_template(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import DeleteSmsTemplateRequest
req = DeleteSmsTemplateRequest(template_code=args.code)
resp = client.delete_sms_template(req)
body = resp.body
if body.code == 'OK':
print(f'✅ 模板 {args.code} 已删除')
else:
print(f'ERROR: {body.code} - {body.message}')
# ──────────────── 发送记录查询 ────────────────
def query_send_details(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import QuerySendDetailsRequest
phone = args.phone.lstrip('+86')
send_date = args.date or datetime.now().strftime('%Y%m%d')
req = QuerySendDetailsRequest(
phone_number=phone,
send_date=send_date,
page_size=args.size,
current_page=args.page,
)
resp = client.query_send_details(req)
body = resp.body
if body.code != 'OK':
print(f'ERROR: {body.code} - {body.message}')
return
details = body.sms_send_detail_dtos.sms_send_detail_dto if body.sms_send_detail_dtos else []
print(f'\n📋 发送记录 (手机: {args.phone}, 日期: {send_date}, 共 {body.total_count} 条)\n')
print(f'{"发送时间":<22} {"状态":<8} {"模板Code":<18} {"内容"}')
print('-' * 90)
status_map = {1: '等待', 2: '失败', 3: '✅ 成功'}
for d in details:
st = status_map.get(d.send_status, str(d.send_status))
content = (d.content or '')[:40]
print(f'{d.send_date:<22} {st:<8} {d.template_code:<18} {content}')
# ──────────────── 发送统计 ────────────────
def query_send_statistics(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import QuerySendStatisticsRequest
today = datetime.now()
start = args.start or (today - timedelta(days=args.days)).strftime('%Y%m%d')
end = args.end or today.strftime('%Y%m%d')
req = QuerySendStatisticsRequest(
is_globe=1,
start_date=start,
end_date=end,
page_index=1,
page_size=50,
)
resp = client.query_send_statistics(req)
body = resp.body
if body.code != 'OK':
print(f'ERROR: {body.code} - {body.message}')
return
data = body.data
items = data.target_list or [] if data else []
total_sent = sum(i.total_count for i in items)
total_success = sum(i.responded_success_count for i in items)
total_fail = sum(i.responded_fail_count for i in items)
print(f'\n📋 SMS 发送统计 ({start} ~ {end})\n')
print(f'{"日期":<12} {"发送":<8} {"成功":<8} {"失败":<8}')
print('-' * 40)
for i in items:
print(f'{i.send_date:<12} {i.total_count:<8} {i.responded_success_count:<8} {i.responded_fail_count:<8}')
print('-' * 40)
print(f'{"合计":<12} {total_sent:<8} {total_success:<8} {total_fail:<8}')
print(f'\n 注: SMS 为按量付费,无资源包余额概念')
# ──────────────── 发送测试 ────────────────
def send_sms(args):
client = get_client()
from alibabacloud_dysmsapi20170525.models import SendSmsRequest
from alibabacloud_tea_util.models import RuntimeOptions
phone = args.phone
if not phone.startswith('+'):
phone = '+86' + phone.lstrip('0')
req = SendSmsRequest(
phone_numbers=phone,
sign_name=args.sign or os.environ.get('ALIYUN_SMS_SIGN_NAME', ''),
template_code=args.template or os.environ.get('ALIYUN_SMS_TEMPLATE_CODE', ''),
template_param=json.dumps({'code': args.code}) if args.code else None,
)
runtime = RuntimeOptions(connect_timeout=15000, read_timeout=15000)
resp = client.send_sms_with_options(req, runtime)
body = resp.body
if body.code == 'OK':
print(f'✅ 短信发送成功: phone={phone} bizId={body.biz_id}')
else:
print(f'ERROR: {body.code} - {body.message}')
# ──────────────── CLI ────────────────
def main():
parser = argparse.ArgumentParser(description='阿里云 SMS 管理工具')
sub = parser.add_subparsers(dest='command')
# 签名
p = sub.add_parser('list-signs', help='列出所有短信签名')
p.add_argument('--page', type=int, default=1)
p.add_argument('--size', type=int, default=50)
p = sub.add_parser('create-sign', help='创建短信签名')
p.add_argument('--name', required=True, help='签名名称')
p.add_argument('--source', type=int, default=0, help='来源: 0=企事业 1=工信部 2=商标 3=APP 4=网站 5=公众号 6=小程序 7=电商')
p.add_argument('--remark', default='', help='备注说明')
p = sub.add_parser('delete-sign', help='删除短信签名')
p.add_argument('--name', required=True, help='签名名称')
# 模板
p = sub.add_parser('list-templates', help='列出所有短信模板')
p.add_argument('--page', type=int, default=1)
p.add_argument('--size', type=int, default=50)
p = sub.add_parser('create-template', help='创建短信模板')
p.add_argument('--name', required=True, help='模板名称')
p.add_argument('--content', required=True, help='模板内容,如: 您的验证码为${code}')
p.add_argument('--type', type=int, default=0, help='类型: 0=验证码 1=通知 2=推广')
p.add_argument('--remark', default='', help='备注说明')
p.add_argument('--sign', default='', help='关联签名名称新API必填默认读ALIYUN_SMS_SIGN_NAME')
p.add_argument('--rule', default='', help='变量规则JSON如: {"code":"numberCaptcha"}')
p = sub.add_parser('delete-template', help='删除短信模板')
p.add_argument('--code', required=True, help='模板Code')
# 发送记录
p = sub.add_parser('query', help='查询发送记录')
p.add_argument('--phone', required=True, help='手机号')
p.add_argument('--date', help='日期 YYYYMMDD (默认今天)')
p.add_argument('--page', type=int, default=1)
p.add_argument('--size', type=int, default=20)
# 发送统计
p = sub.add_parser('stats', help='查询发送统计')
p.add_argument('--days', type=int, default=30, help='最近N天 (默认30)')
p.add_argument('--start', help='起始日期 YYYYMMDD')
p.add_argument('--end', help='结束日期 YYYYMMDD')
# 发送测试
p = sub.add_parser('send', help='发送短信 (测试)')
p.add_argument('--phone', required=True, help='手机号')
p.add_argument('--sign', help='签名 (默认取环境变量)')
p.add_argument('--template', help='模板Code (默认取环境变量)')
p.add_argument('--code', help='验证码内容')
args = parser.parse_args()
if not args.command:
parser.print_help()
return
cmds = {
'list-signs': list_signs,
'create-sign': create_sign,
'delete-sign': delete_sign,
'list-templates': list_templates,
'create-template': create_template,
'delete-template': delete_template,
'query': query_send_details,
'stats': query_send_statistics,
'send': send_sms,
}
try:
cmds[args.command](args)
except Exception as e:
err = str(e)
if 'NoPermission' in err or '403' in err:
print(f'\n❌ 权限不足: RAM 子账号缺少对应 API 权限')
print(f' 请在阿里云 RAM 控制台添加 AliyunDysmsFullAccess 策略')
print(f' 控制台: https://ram.console.aliyun.com')
else:
print(f'ERROR: {e}')
if __name__ == '__main__':
main()