Compare commits
2 Commits
f6c2dcead8
...
b8429efa3d
| Author | SHA1 | Date | |
|---|---|---|---|
| b8429efa3d | |||
| 20645d6d17 |
+3
-1
@@ -8,4 +8,6 @@ ENCRYPTION_KEY=replace-with-generated-fernet-key
|
|||||||
LOCAL_PORT=8080
|
LOCAL_PORT=8080
|
||||||
# Public deployment only; bare DNS hostname, without scheme or path.
|
# Public deployment only; bare DNS hostname, without scheme or path.
|
||||||
DOMAIN=alpha.example.com
|
DOMAIN=alpha.example.com
|
||||||
# WorldQuant email/password are configured after system login, not in this file.
|
# WorldQuant credentials; configure both. Single quotes preserve literal $ and #.
|
||||||
|
WQ_EMAIL=
|
||||||
|
WQ_PASSWORD=
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Deploy production
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Register a dedicated runner on the target host with label wq-production:host.
|
||||||
|
runs-on: wq-production
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Build, migrate and deploy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
|
||||||
|
WQ_EMAIL: ${{ secrets.WQ_EMAIL }}
|
||||||
|
WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }}
|
||||||
|
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||||
|
ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }}
|
||||||
|
DATABASE_NETWORK: ${{ vars.DATABASE_NETWORK }}
|
||||||
|
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
|
||||||
|
run: bash scripts/deploy-production.sh
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Gitea 生产部署
|
||||||
|
|
||||||
|
采用已选择的 compound 经验 1,经当前项目核验:复用锁定依赖的 Dockerfile、单 worker 和数据库健康接口;新增独立生产 Compose、外部 PostgreSQL 配置、同机 host Runner 工作流。生产凭据通过 Gitea Secrets 注入,非敏感配置通过 Variables 注入,不生成凭据文件。数据库网络为可配置参数,不沿用旧项目常量作为强制约定。
|
||||||
|
|
||||||
|
部署在构建完成后停止写入、执行一次性迁移,再启动健康检查。固定项目名与按提交标记镜像;使用宿主机文件锁串行化。保留本地和独立公网部署的现有行为。不执行远程部署、不修改知识库。
|
||||||
|
|
||||||
|
验证:Compose 展开及缺失配置拒绝、脚本语法、镜像构建、隔离数据库上的迁移和 HTTP 健康检查。真实服务器 Runner、数据库、TLS 入口需首次部署验收。
|
||||||
|
|
||||||
|
## 验证结果(2026-09-09)
|
||||||
|
|
||||||
|
- Docker Engine 29.6.2 / Compose 5.3.1:默认和 jobs profile 配置校验通过;5 个必填配置缺失时均拒绝展开。
|
||||||
|
- 前后端生产镜像构建通过(前端依赖 lottie-web 存在既有 eval 构建警告)。
|
||||||
|
- 独立测试项目、临时 PostgreSQL 17、独立外部网络:首次迁移、页面/API 健康、管理员登录与 Secure/HttpOnly Cookie、重复迁移和重启均通过。测试容器、卷和网络已清理,未操作现有应用数据库。
|
||||||
|
- Bash 语法与 workflow YAML 解析通过;模拟 Docker 验证成功流程及构建、预检、迁移、健康失败分支,确认提前失败不停止服务、迁移失败不启动服务、失败不记录成功版本。
|
||||||
|
- 实际 Gitea/Runner、Linux flock 互斥、公网 TLS、生产库与恢复流程未在目标服务器验证。本机存在用户并行前端改动,本任务未修改这些文件。
|
||||||
|
- 经验 1 已应用;后端无可写 named volume,未添加不适用的卷初始化 Job;凭据按用户最新要求由 Gitea 注入。知识库未修改。
|
||||||
|
|
||||||
|
## Gitea Secrets 调整
|
||||||
|
|
||||||
|
按用户确认改为步骤级 Secrets/Variables 注入;删除生产环境示例文件和相关忽略例外,不再依赖服务器凭据文件。脚本显式使用 `/dev/null` 作为 env-file,5 个必填值缺失时立即停止;锁与版本记录独立保存在 `/opt/wq-alpha`。
|
||||||
|
|
||||||
|
本轮验证:Compose 默认/jobs 展开、5 个缺失配置拒绝、真实隔离容器内特殊字符密码完整性、Bash 语法和工作流映射均通过。模拟 Docker 验证成功、锁冲突、缺失变量和构建/预检/迁移/健康失败分支;未输出密钥。未重新运行未变化的迁移和前端构建;真实 Gitea 注入仍待服务器运行确认。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 统一列表与详情交互
|
||||||
|
Type: task
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
六个研究模块使用 table + detail,除 QuantFlow 独立详情页外均为 60% 右侧抽屉。所有列表页连续 Flex 高度链,移除页面顶部标题描述,按钮区位于筛选下方。
|
||||||
|
|
||||||
|
## Selected learning
|
||||||
|
采用 20260724-bounded-list-flex-height-chain:当前应用外壳已有有界 Flex,研究页面仍整页滚动。改为固定控制区和分页、表格数据区独立滚动。Semi UI 2.103.0 本机类型与当前官方文档均支持 SideSheet 字符串 width。
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
完成:`pnpm --dir frontend typecheck`、`pnpm --dir frontend build`、`git diff --check` 通过。
|
||||||
|
相关 Playwright 回归 11/11 通过(Alpha 管理、回测、数据目录、QuantFlow、研究流水线、跨页研究导航、特征转模板)。
|
||||||
|
隔离测试服务与浏览器数据验证:1440px/390px 视口抽屉宽度分别 864px/234px;长列表只滚动数据区,分页 y=834 不随内容滚动;body 高度不超过视口。QuantFlow 独立详情支持前进、后退、刷新。
|
||||||
|
独立只读核验 `/root/route_review` complete:未发现路由或草稿恢复的严重阻断。
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Completion: complete
|
||||||
|
应用选定 Flex 高度链经验,未修改经验库。共享 ResearchList 管理固定控制区、分页及数据视口;ResearchDetail 使用 60% 右侧抽屉。QuantFlow 使用独立 hash 详情路由。保留工作区同时出现的部署相关改动,本次未提交或部署。
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/init_env.py
|
python3 scripts/init_env.py
|
||||||
|
# 编辑 .env,填写 WQ_EMAIL 与 WQ_PASSWORD,再启动
|
||||||
docker compose up -d --build --wait
|
docker compose up -d --build --wait
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
@@ -20,7 +21,7 @@ docker compose ps
|
|||||||
|
|
||||||
首次使用:
|
首次使用:
|
||||||
|
|
||||||
1. 登录系统,在“个人信息”保存 WorldQuant 邮箱和密码,点击“连接 WorldQuant”。
|
1. 登录系统,在“个人信息 → 连接设置”点击“连接 WorldQuant”。
|
||||||
2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。
|
2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。
|
||||||
3. 在“Alpha 管理”切换“待提交 / 已提交”。待提交先选创建日期范围,按天同步;已提交可选提交日期范围按天同步,或全量同步。相同起止日期表示单日,日期边界为 UTC,均包含隐藏记录。也可导入指定 Alpha ID。任务面板显示当前日期、进度、错误、取消和重试。
|
3. 在“Alpha 管理”切换“待提交 / 已提交”。待提交先选创建日期范围,按天同步;已提交可选提交日期范围按天同步,或全量同步。相同起止日期表示单日,日期边界为 UTC,均包含隐藏记录。也可导入指定 Alpha ID。任务面板显示当前日期、进度、错误、取消和重试。
|
||||||
4. 点击 Alpha 打开详情。研究记录保存在本地;PnL 点击获取后缓存。详情的“本地自相关”可发起检测,列表也可选中最多 100 条批量检测。建议先全量同步已提交 Alpha,建立比较基准。下次同步会更新平台数据并保留本地研究记录。
|
4. 点击 Alpha 打开详情。研究记录保存在本地;PnL 点击获取后缓存。详情的“本地自相关”可发起检测,列表也可选中最多 100 条批量检测。建议先全量同步已提交 Alpha,建立比较基准。下次同步会更新平台数据并保留本地研究记录。
|
||||||
@@ -83,6 +84,10 @@ Chatbox 来源使用 `kind=chatbox`,会话 ID 为 `reference`,生成轮次 I
|
|||||||
|
|
||||||
公共接口位于 `/api/v1/backtests`,对接与验证记录见 [实施规格](.scratch/backtest/spec.md) 和 [回测验收记录](.scratch/backtest/verification.md)。真实平台权限、当前协议与限额尚未联调。
|
公共接口位于 `/api/v1/backtests`,对接与验证记录见 [实施规格](.scratch/backtest/spec.md) 和 [回测验收记录](.scratch/backtest/verification.md)。真实平台权限、当前协议与限额尚未联调。
|
||||||
|
|
||||||
|
## Gitea 自动部署(复用已有 PostgreSQL)
|
||||||
|
|
||||||
|
使用独立的 `compose.production.yaml` 和 `.gitea/workflows/deploy-production.yaml`。配置步骤、数据库账号密码位置及升级处理见 [Gitea 部署说明](docs/deployment-gitea.md)。
|
||||||
|
|
||||||
## 公网 HTTPS 部署
|
## 公网 HTTPS 部署
|
||||||
|
|
||||||
`compose.public.yaml` 是独立配置,不与本机配置叠加。先在服务器完成上面的密钥初始化,将 `.env` 中 `DOMAIN` 改为自己的域名(无协议、路径、端口)。DNS 指向服务器,允许入站 TCP 80/443,UDP 443 可选。
|
`compose.public.yaml` 是独立配置,不与本机配置叠加。先在服务器完成上面的密钥初始化,将 `.env` 中 `DOMAIN` 改为自己的域名(无协议、路径、端口)。DNS 指向服务器,允许入站 TCP 80/443,UDP 443 可选。
|
||||||
@@ -103,6 +108,7 @@ docker compose -f compose.public.yaml logs --tail=100 web
|
|||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 仅首次空库初始化管理员,重启不会重置现有密码 |
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 仅首次空库初始化管理员,重启不会重置现有密码 |
|
||||||
| `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 |
|
| `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 |
|
||||||
| `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码和模型 API Key |
|
| `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码和模型 API Key |
|
||||||
|
| `WQ_EMAIL` / `WQ_PASSWORD` | WorldQuant 邮箱和密码,成对设置;本地 `.env`,生产 Gitea Secrets |
|
||||||
| `LOCAL_PORT` | 本机入口端口,默认 8080 |
|
| `LOCAL_PORT` | 本机入口端口,默认 8080 |
|
||||||
| `DOMAIN` | 公网域名 |
|
| `DOMAIN` | 公网域名 |
|
||||||
| `AI_REQUEST_LIMIT` | 每轮模型请求上限,默认 12 |
|
| `AI_REQUEST_LIMIT` | 每轮模型请求上限,默认 12 |
|
||||||
@@ -110,6 +116,8 @@ docker compose -f compose.public.yaml logs --tail=100 web
|
|||||||
| `AI_OUTPUT_TOKENS` | 每次模型输出上限,默认 4096 |
|
| `AI_OUTPUT_TOKENS` | 每次模型输出上限,默认 4096 |
|
||||||
| `AI_TIMEOUT` | 每轮累计活动执行时限(秒),默认 180,等待确认不计入 |
|
| `AI_TIMEOUT` | 每轮累计活动执行时限(秒),默认 180,等待确认不计入 |
|
||||||
|
|
||||||
|
WorldQuant 凭据不再从 `account.json` 读取。进程环境变量优先于项目根目录 `.env`;启动时更新数据库中的加密凭据,配置后页面禁止覆盖。修改凭据后重建后端容器(Gitea 重新部署);已绑定账户不能更换邮箱。两个变量都未设置时兼容已有页面配置,只有一个时启动失败。
|
||||||
|
|
||||||
WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码和模型 API Key;切勿在正常升级时重新生成它。
|
WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码和模型 API Key;切勿在正常升级时重新生成它。
|
||||||
|
|
||||||
修改系统密码(同时撤销所有系统会话):
|
修改系统密码(同时撤销所有系统会话):
|
||||||
|
|||||||
+10
-1
@@ -1,12 +1,16 @@
|
|||||||
"""Deployment configuration; secrets are required and never included in API responses."""
|
"""Deployment configuration; secrets are required and never included in API responses."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from pydantic import Field, SecretStr, model_validator
|
from pydantic import Field, SecretStr, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file="../.env", extra="ignore")
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore", hide_input_in_errors=True
|
||||||
|
)
|
||||||
|
|
||||||
database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
|
database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
@@ -15,6 +19,8 @@ class Settings(BaseSettings):
|
|||||||
public_origin: str = "http://localhost:8080"
|
public_origin: str = "http://localhost:8080"
|
||||||
cookie_secure: bool = False
|
cookie_secure: bool = False
|
||||||
session_hours: int = Field(default=24, ge=1, le=168)
|
session_hours: int = Field(default=24, ge=1, le=168)
|
||||||
|
wq_email: str = ""
|
||||||
|
wq_password: SecretStr = SecretStr("")
|
||||||
wq_base_url: str = "https://api.worldquantbrain.com"
|
wq_base_url: str = "https://api.worldquantbrain.com"
|
||||||
request_timeout: float = 30
|
request_timeout: float = 30
|
||||||
retry_attempts: int = Field(default=4, ge=1, le=8)
|
retry_attempts: int = Field(default=4, ge=1, le=8)
|
||||||
@@ -26,6 +32,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_secrets(self):
|
def validate_secrets(self):
|
||||||
|
self.wq_email = self.wq_email.strip()
|
||||||
|
if bool(self.wq_email) != bool(self.wq_password.get_secret_value()):
|
||||||
|
raise ValueError("WQ_EMAIL and WQ_PASSWORD must be configured together")
|
||||||
Fernet(self.encryption_key.get_secret_value().encode())
|
Fernet(self.encryption_key.get_secret_value().encode())
|
||||||
if self.cookie_secure and not self.public_origin.startswith("https://"):
|
if self.cookie_secure and not self.public_origin.startswith("https://"):
|
||||||
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
|
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
|
||||||
|
|||||||
+7
-4
@@ -53,7 +53,7 @@ from .schemas import (
|
|||||||
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
|
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
|
||||||
|
|
||||||
|
|
||||||
def account_output(account, client):
|
def account_output(account, client, settings):
|
||||||
keys = (
|
keys = (
|
||||||
"email",
|
"email",
|
||||||
"wq_user_id",
|
"wq_user_id",
|
||||||
@@ -70,6 +70,7 @@ def account_output(account, client):
|
|||||||
return {
|
return {
|
||||||
**{k: getattr(account, k) for k in keys},
|
**{k: getattr(account, k) for k in keys},
|
||||||
"configured": bool(account.password_encrypted),
|
"configured": bool(account.password_encrypted),
|
||||||
|
"credentials_source": "environment" if settings.wq_email else "database",
|
||||||
"session": client.session_info(),
|
"session": client.session_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +211,12 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
@api.get("/account", response_model=AccountOutput, tags=["account"])
|
@api.get("/account", response_model=AccountOutput, tags=["account"])
|
||||||
async def get_account():
|
async def get_account():
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
return account_output(await db.get(Account, 1), runner.client)
|
return account_output(await db.get(Account, 1), runner.client, settings)
|
||||||
|
|
||||||
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
|
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
|
||||||
async def credentials(body: CredentialsInput):
|
async def credentials(body: CredentialsInput):
|
||||||
|
if settings.wq_email:
|
||||||
|
raise HTTPException(409, "WorldQuant 凭据由环境变量管理,请修改部署配置并重启服务")
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
account = await db.get(Account, 1)
|
account = await db.get(Account, 1)
|
||||||
if account.wq_user_id and account.email.casefold() != body.email.casefold():
|
if account.wq_user_id and account.email.casefold() != body.email.casefold():
|
||||||
@@ -224,7 +227,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
account.email = body.email
|
account.email = body.email
|
||||||
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
|
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return account_output(account, runner.client)
|
return account_output(account, runner.client, settings)
|
||||||
|
|
||||||
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
|
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
|
||||||
async def preferences(body: PreferencesInput):
|
async def preferences(body: PreferencesInput):
|
||||||
@@ -233,7 +236,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
|||||||
for key, value in body.model_dump().items():
|
for key, value in body.model_dump().items():
|
||||||
setattr(account, key, value)
|
setattr(account, key, value)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return account_output(account, runner.client)
|
return account_output(account, runner.client, settings)
|
||||||
|
|
||||||
async def account_job(kind):
|
async def account_job(kind):
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ class PlatformSessionOutput(BaseModel):
|
|||||||
class AccountOutput(BaseModel):
|
class AccountOutput(BaseModel):
|
||||||
email: str | None
|
email: str | None
|
||||||
configured: bool
|
configured: bool
|
||||||
|
credentials_source: Literal["environment", "database"]
|
||||||
wq_user_id: str | None
|
wq_user_id: str | None
|
||||||
profile: dict
|
profile: dict
|
||||||
connection_status: str
|
connection_status: str
|
||||||
|
|||||||
+13
-3
@@ -26,7 +26,7 @@ def cipher(settings) -> Fernet:
|
|||||||
|
|
||||||
|
|
||||||
async def bootstrap(db, settings):
|
async def bootstrap(db, settings):
|
||||||
"""Only initialize missing singleton records; deployments never reset existing passwords."""
|
"""Initialize singletons and apply environment credentials without resetting the admin password."""
|
||||||
if not await db.get(Admin, 1):
|
if not await db.get(Admin, 1):
|
||||||
db.add(
|
db.add(
|
||||||
Admin(
|
Admin(
|
||||||
@@ -35,8 +35,18 @@ async def bootstrap(db, settings):
|
|||||||
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
|
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not await db.get(Account, 1):
|
account = await db.get(Account, 1)
|
||||||
db.add(Account(id=1))
|
if account is None:
|
||||||
|
account = Account(id=1)
|
||||||
|
db.add(account)
|
||||||
|
if settings.wq_email:
|
||||||
|
# The environment must not bypass the single-account data ownership boundary.
|
||||||
|
if account.wq_user_id and (account.email or "").casefold() != settings.wq_email.casefold():
|
||||||
|
raise ValueError("WQ_EMAIL conflicts with the bound WorldQuant account")
|
||||||
|
account.email = settings.wq_email
|
||||||
|
account.password_encrypted = cipher(settings).encrypt(
|
||||||
|
settings.wq_password.get_secret_value().encode()
|
||||||
|
).decode()
|
||||||
await db.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
|
await db.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Environment credential precedence, rotation and account ownership boundaries."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr, ValidationError
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.models import Account, Admin
|
||||||
|
from app.security import bootstrap, cipher
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotenv_and_process_precedence(settings, tmp_path, monkeypatch):
|
||||||
|
env = tmp_path / '.env'
|
||||||
|
env.write_text("WQ_EMAIL=local@example.com\nWQ_PASSWORD='literal-$value#password'\n")
|
||||||
|
values = settings.model_dump(exclude={'wq_email', 'wq_password'})
|
||||||
|
local = Settings(_env_file=env, **values)
|
||||||
|
assert local.wq_email == 'local@example.com'
|
||||||
|
assert local.wq_password.get_secret_value() == 'literal-$value#password'
|
||||||
|
monkeypatch.setenv('WQ_EMAIL', 'production@example.com')
|
||||||
|
monkeypatch.setenv('WQ_PASSWORD', 'production-secret')
|
||||||
|
production = Settings(_env_file=env, **values)
|
||||||
|
assert production.wq_email == 'production@example.com'
|
||||||
|
assert production.wq_password.get_secret_value() == 'production-secret'
|
||||||
|
assert 'production-secret' not in repr(production)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('email,password', [('person@example.com', ''), ('', 'private-value')])
|
||||||
|
def test_partial_configuration_fails_without_leaking(settings, email, password):
|
||||||
|
values = settings.model_dump(exclude={'wq_email', 'wq_password'})
|
||||||
|
with pytest.raises(ValidationError) as error:
|
||||||
|
Settings(_env_file=None, **values, wq_email=email, wq_password=password)
|
||||||
|
assert 'must be configured together' in str(error.value)
|
||||||
|
assert 'private-value' not in str(error.value)
|
||||||
|
assert 'person@example.com' not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_env_rotation_api_lock_and_bound_account(app, logged_in):
|
||||||
|
settings = app.state.settings
|
||||||
|
settings.wq_email = 'person@example.com'
|
||||||
|
settings.wq_password = SecretStr('initial-env-secret')
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
original_admin = (await db.get(Admin, 1)).password_hash
|
||||||
|
await bootstrap(db, settings)
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
assert cipher(settings).decrypt(account.password_encrypted.encode()) == b'initial-env-secret'
|
||||||
|
account.wq_user_id = 'bound-user'
|
||||||
|
await db.commit()
|
||||||
|
settings.wq_password = SecretStr('rotated-env-secret')
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
await bootstrap(db, settings)
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
assert cipher(settings).decrypt(account.password_encrypted.encode()) == b'rotated-env-secret'
|
||||||
|
assert (await db.get(Admin, 1)).password_hash == original_admin
|
||||||
|
response = await logged_in.get('/api/v1/account')
|
||||||
|
assert response.json()['credentials_source'] == 'environment'
|
||||||
|
assert response.json()['configured'] is True
|
||||||
|
assert 'secret' not in response.text and 'password' not in response.text
|
||||||
|
response = await logged_in.put('/api/v1/account/credentials', json={
|
||||||
|
'email': 'person@example.com', 'password': 'manual-secret',
|
||||||
|
})
|
||||||
|
assert response.status_code == 409
|
||||||
|
settings.wq_email = 'other@example.com'
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
with pytest.raises(ValueError, match='bound WorldQuant account'):
|
||||||
|
await bootstrap(db, settings)
|
||||||
|
await db.rollback()
|
||||||
|
assert (await db.get(Account, 1)).email == 'person@example.com'
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Independent production configuration; do not merge with compose.yaml.
|
||||||
|
name: wq-alpha-production
|
||||||
|
|
||||||
|
x-backend: &backend
|
||||||
|
image: wq-alpha-production-backend:${DEPLOY_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?Set the Gitea DATABASE_URL secret}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY}
|
||||||
|
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set the public HTTPS origin}
|
||||||
|
WQ_EMAIL: ${WQ_EMAIL:?Set the Gitea WQ_EMAIL secret}
|
||||||
|
WQ_PASSWORD: ${WQ_PASSWORD:?Set the Gitea WQ_PASSWORD secret}
|
||||||
|
COOKIE_SECURE: "true"
|
||||||
|
AI_REQUEST_LIMIT: ${AI_REQUEST_LIMIT:-12}
|
||||||
|
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
|
||||||
|
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
||||||
|
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
||||||
|
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- database
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
<<: *backend
|
||||||
|
# Migration is run separately with all application writers stopped.
|
||||||
|
command: [uvicorn, 'app.main:create_app', --factory, --host, 0.0.0.0, --port, '8000', --workers, '1', --proxy-headers]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=3)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
stop_grace_period: 60s
|
||||||
|
restart: unless-stopped
|
||||||
|
migrate:
|
||||||
|
<<: *backend
|
||||||
|
profiles: [jobs]
|
||||||
|
command: [alembic, upgrade, head]
|
||||||
|
restart: 'no'
|
||||||
|
web:
|
||||||
|
image: wq-alpha-production-web:${DEPLOY_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
environment:
|
||||||
|
SITE_ADDRESS: http://:80
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:${WEB_PORT:-8112}:80'
|
||||||
|
volumes:
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, 'wget -q -O /dev/null http://127.0.0.1/api/v1/health && wget -q -O /dev/null http://127.0.0.1/']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
database:
|
||||||
|
external: true
|
||||||
|
name: ${DATABASE_NETWORK:?Set the existing PostgreSQL Docker network}
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
@@ -29,6 +29,8 @@ services:
|
|||||||
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
|
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
|
||||||
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
||||||
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
||||||
|
WQ_EMAIL: ${WQ_EMAIL:-}
|
||||||
|
WQ_PASSWORD: ${WQ_PASSWORD:-}
|
||||||
COOKIE_SECURE: "true"
|
COOKIE_SECURE: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ services:
|
|||||||
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
|
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
|
||||||
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
||||||
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
||||||
|
WQ_EMAIL: ${WQ_EMAIL:-}
|
||||||
|
WQ_PASSWORD: ${WQ_PASSWORD:-}
|
||||||
COOKIE_SECURE: "false"
|
COOKIE_SECURE: "false"
|
||||||
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Gitea 生产部署
|
||||||
|
|
||||||
|
本方案在目标 Linux 服务器上由 Gitea host Runner 构建并启动 Docker Compose,复用已有 PostgreSQL。入口绑定 `127.0.0.1:8112`,由宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。
|
||||||
|
|
||||||
|
## 1. 填写配置和数据库账号密码
|
||||||
|
|
||||||
|
进入 **Gitea 仓库 → 设置 → Actions → Secrets / Variables**,按下表创建同名配置。敏感值填写在 Secrets,工作流仅在部署步骤通过环境变量注入,不生成服务器凭据文件。
|
||||||
|
|
||||||
|
| 位置 | 名称 | 填写内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@数据库网络别名:5432/数据库名` |
|
||||||
|
| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 |
|
||||||
|
| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 |
|
||||||
|
| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 |
|
||||||
|
| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 |
|
||||||
|
| Variables(必填) | `DATABASE_NETWORK` | PostgreSQL 所在的现有 Docker 网络,例如 `1panel-network` |
|
||||||
|
| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` |
|
||||||
|
| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` |
|
||||||
|
|
||||||
|
端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。
|
||||||
|
|
||||||
|
数据库连接串示例(实际填写时替换示例值):
|
||||||
|
|
||||||
|
```text
|
||||||
|
postgresql+asyncpg://wq_user:YOUR_PASSWORD@postgresql:5432/wq_alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
生成加密密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATABASE_URL` 的用户名和密码中的特殊字符须做 URL 百分号编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`、`%` → `%25`。在 Gitea 输入框填写值本身,不添加包裹引号,不填写 `DATABASE_URL=` 前缀。管理员密码中的 `$`、引号等按原样填写,由环境变量传递,无需 shell 转义。不要在日志打印变量,或开启 `set -x`。
|
||||||
|
|
||||||
|
在现有 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。确认网络存在、数据库别名可解析、PostgreSQL 允许该 Docker 网络访问。应用使用异步驱动 `asyncpg`;远程数据库或强制 TLS 的实例需另外按该实例的 TLS 配置调整连接参数,此模板默认同机 Docker 网络。
|
||||||
|
|
||||||
|
如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。
|
||||||
|
|
||||||
|
WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面只保留连接操作。修改 `WQ_PASSWORD` 后重新运行部署即可更新。`WQ_EMAIL` 必须与已有绑定账户一致,避免混入其他账户数据;生产不读取 `account.json` 或本地 `.env`。
|
||||||
|
|
||||||
|
## 2. 配置 Gitea Runner
|
||||||
|
|
||||||
|
在目标 Docker 宿主机直接运行专用 Runner,注册标签 **`wq-production:host`**,工作流的 `runs-on` 对应 `wq-production`。建议专用 Runner 配置 `runner.capacity: 1`。不要使用指向其他机器的 Docker context;这里的 host 模式也不能被当成“容器化 Runner 自动进入宿主机”。
|
||||||
|
|
||||||
|
Runner 用户需要 Docker 权限,以及写入预先创建的 `/opt/wq-alpha` 目录的权限。该目录仅保存锁文件和版本记录,不保存凭据。宿主机需具备 Git、Bash、Node.js 20(checkout v4)、`flock`(通常由 util-linux 提供)和支持 `up --wait --wait-timeout` 的 Docker Compose v2 或 v5。镜像使用锁文件构建,服务器需能访问 GitHub checkout action、基础镜像仓库和依赖源。本地验证环境为 Docker Engine 29.6.2、Compose 5.3.1;你的 Gitea/Runner 版本需在首次运行核验。
|
||||||
|
|
||||||
|
在 Gitea 仓库启用 Actions,提交并推送这些配置后,`main` 的 push 或手动运行会部署。Runner 应只接收可信仓库的任务,因为它具备生产主机 Docker 权限。凭据通过上述 Secrets 注入,脚本使用 `--env-file /dev/null` 防止误读检出目录的开发 `.env`。部署脚本用 `/opt/wq-alpha/deploy.lock` 实现跨 checkout 的互斥,冲突部署直接失败,可稍后重新运行。
|
||||||
|
|
||||||
|
## 3. 配置反向代理并首次运行
|
||||||
|
|
||||||
|
为 `PUBLIC_ORIGIN` 对应域名配置 HTTPS,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。代理须运行在宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。
|
||||||
|
|
||||||
|
首次部署建议在 Gitea Actions 页面手动运行工作流。需要在服务器排障运行时,须先从安全渠道将上表必填配置注入当前进程环境,再执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/deploy-production.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。
|
||||||
|
|
||||||
|
首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。
|
||||||
|
|
||||||
|
## 4. 升级、备份和失败处理
|
||||||
|
|
||||||
|
升级包含停机窗口;提前结束或暂停长时间任务。每次发布前通过现有数据库管理工具备份专用库,并在独立安全位置备份加密密钥及必要配置,先在独立库验证恢复。此工作流不会自动备份或自动恢复数据库。
|
||||||
|
|
||||||
|
构建及预检失败时旧服务继续运行。停止服务后的迁移或健康检查失败需要人工处理;不要对可能已变更的 schema 直接自动降级。脚本将切换前的镜像 ID/标签记录到 `/opt/wq-alpha/previous-images.txt`,最后成功的提交记录到 `current-release.txt`,保留旧镜像且不执行 prune。失败重试前另存这些记录,避免后续尝试覆盖回退参考。
|
||||||
|
|
||||||
|
如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。
|
||||||
|
|
||||||
|
以下排查命令不依赖凭据环境变量(可能含业务信息的日志请勿公开):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps -a --filter label=com.docker.compose.project=wq-alpha-production
|
||||||
|
docker logs --tail=100 wq-alpha-production-backend-1
|
||||||
|
docker logs --tail=100 wq-alpha-production-web-1
|
||||||
|
```
|
||||||
|
|
||||||
|
普通维护不要使用 `down -v`。这里的数据库由外部管理,备份与恢复应在数据库管理端进行。
|
||||||
|
|
||||||
|
## 配置依据
|
||||||
|
|
||||||
|
采用 compound 经验 1 中的生产/开发隔离、显式外部网络、必填凭据、固定项目名、独立迁移和部署健康检查。未照搬旧项目端口、业务 Job 或卷权限初始化:本应用后端不写 named volume,已有镜像使用 UID 10001。
|
||||||
|
|
||||||
|
凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 外部网络](https://docs.docker.com/reference/compose-file/networks/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 标签](https://gitea.com/gitea/runner/src/branch/main/README.md)。
|
||||||
@@ -24,7 +24,8 @@ export function contextLabel(context: PageContext): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pageFromHash(hash: string): WorkspacePage {
|
export function pageFromHash(hash: string): WorkspacePage {
|
||||||
const page = hash.replace(/^#/, "");
|
const value = hash.replace(/^#/, "");
|
||||||
|
const page = value.startsWith("quantflow/") ? "quantflow" : value;
|
||||||
return Object.hasOwn(contextLabels, page)
|
return Object.hasOwn(contextLabels, page)
|
||||||
? (page as WorkspacePage)
|
? (page as WorkspacePage)
|
||||||
: "alphas";
|
: "alphas";
|
||||||
|
|||||||
@@ -318,7 +318,8 @@ export function BacktestPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="backtest-spacer" />
|
</div>
|
||||||
|
<div className="backtest-toolbar">
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -438,7 +439,7 @@ export function BacktestPage({
|
|||||||
<SideSheet
|
<SideSheet
|
||||||
title={preview ? "确认回测输入" : "准备候选"}
|
title={preview ? "确认回测输入" : "准备候选"}
|
||||||
visible={editor && drawerVisible}
|
visible={editor && drawerVisible}
|
||||||
width={Math.min(880, window.innerWidth - chatOffset)}
|
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
||||||
style={{ right: chatOffset }}
|
style={{ right: chatOffset }}
|
||||||
maskStyle={{ right: chatOffset }}
|
maskStyle={{ right: chatOffset }}
|
||||||
onCancel={() => setEditor(false)}
|
onCancel={() => setEditor(false)}
|
||||||
@@ -730,7 +731,7 @@ export function BacktestPage({
|
|||||||
<SideSheet
|
<SideSheet
|
||||||
title={run?.name || "回测详情"}
|
title={run?.name || "回测详情"}
|
||||||
visible={!!runId && drawerVisible}
|
visible={!!runId && drawerVisible}
|
||||||
width={Math.min(1120, window.innerWidth - chatOffset)}
|
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
||||||
style={{ right: chatOffset }}
|
style={{ right: chatOffset }}
|
||||||
maskStyle={{ right: chatOffset }}
|
maskStyle={{ right: chatOffset }}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.backtest-toolbar {
|
.backtest-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -65,8 +66,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.backtest-page-view {
|
.backtest-page-view {
|
||||||
height: 100%;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.backtest-tool-summary {
|
.backtest-tool-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function AlphaDetail({
|
|||||||
closeOnEsc={!taskPanelOpen}
|
closeOnEsc={!taskPanelOpen}
|
||||||
className="alpha-detail"
|
className="alpha-detail"
|
||||||
visible={Boolean(id) && !suspended}
|
visible={Boolean(id) && !suspended}
|
||||||
width={Math.min(800, window.innerWidth - chatOffset)}
|
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
||||||
style={{ right: chatOffset }}
|
style={{ right: chatOffset }}
|
||||||
maskStyle={{ right: chatOffset }}
|
maskStyle={{ right: chatOffset }}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -233,9 +233,16 @@ export function AccountPage({
|
|||||||
aria-label="WorldQuant 连接设置"
|
aria-label="WorldQuant 连接设置"
|
||||||
>
|
>
|
||||||
<h2>WorldQuant 连接</h2>
|
<h2>WorldQuant 连接</h2>
|
||||||
|
{account.credentials_source === "environment" && (
|
||||||
|
<p className="muted">
|
||||||
|
凭据由环境变量管理。修改本地 .env 或 Gitea Secrets
|
||||||
|
后重新部署生效。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (account.credentials_source === "environment") return;
|
||||||
void action("save", async () => {
|
void action("save", async () => {
|
||||||
await api("/account/credentials", {
|
await api("/account/credentials", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -246,41 +253,45 @@ export function AccountPage({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="credentials-grid">
|
{account.credentials_source !== "environment" && (
|
||||||
<label>
|
<div className="credentials-grid">
|
||||||
WorldQuant 邮箱
|
<label>
|
||||||
<Input
|
WorldQuant 邮箱
|
||||||
aria-label="WorldQuant 邮箱"
|
<Input
|
||||||
value={email}
|
aria-label="WorldQuant 邮箱"
|
||||||
onChange={setEmail}
|
value={email}
|
||||||
autoComplete="off"
|
onChange={setEmail}
|
||||||
/>
|
autoComplete="off"
|
||||||
</label>
|
/>
|
||||||
<label>
|
</label>
|
||||||
WorldQuant 密码
|
<label>
|
||||||
<Input
|
WorldQuant 密码
|
||||||
aria-label="WorldQuant 密码"
|
<Input
|
||||||
mode="password"
|
aria-label="WorldQuant 密码"
|
||||||
value={password}
|
mode="password"
|
||||||
onChange={setPassword}
|
value={password}
|
||||||
placeholder={
|
onChange={setPassword}
|
||||||
account.configured
|
placeholder={
|
||||||
? "已保存,输入新密码可更新"
|
account.configured
|
||||||
: "输入平台密码"
|
? "已保存,输入新密码可更新"
|
||||||
}
|
: "输入平台密码"
|
||||||
autoComplete="new-password"
|
}
|
||||||
/>
|
autoComplete="new-password"
|
||||||
</label>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
<Button
|
{account.credentials_source !== "environment" && (
|
||||||
type="tertiary"
|
<Button
|
||||||
htmlType="submit"
|
type="tertiary"
|
||||||
disabled={!email || !password || blocked}
|
htmlType="submit"
|
||||||
loading={busy === "save"}
|
disabled={!email || !password || blocked}
|
||||||
>
|
loading={busy === "save"}
|
||||||
保存凭据
|
>
|
||||||
</Button>
|
保存凭据
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
disabled={!account.configured || blocked}
|
disabled={!account.configured || blocked}
|
||||||
|
|||||||
@@ -514,45 +514,6 @@ export function AlphaPage({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<SavedViews
|
|
||||||
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
|
|
||||||
columns={visibleColumns}
|
|
||||||
onRestore={(view) => {
|
|
||||||
const {
|
|
||||||
submission: savedSubmission,
|
|
||||||
sort: savedSort,
|
|
||||||
direction: savedDirection,
|
|
||||||
limit,
|
|
||||||
offset: _offset,
|
|
||||||
...rest
|
|
||||||
} = view.filters;
|
|
||||||
const values = Object.fromEntries(
|
|
||||||
Object.entries(rest)
|
|
||||||
.filter(([, v]) => v !== null && v !== undefined)
|
|
||||||
.map(([k, v]) => [k, String(v)]),
|
|
||||||
);
|
|
||||||
setFilters(values);
|
|
||||||
setDraft(values);
|
|
||||||
setPage(1);
|
|
||||||
setSelected([]);
|
|
||||||
if (
|
|
||||||
savedSubmission === "SUBMITTED" ||
|
|
||||||
savedSubmission === "UNSUBMITTED"
|
|
||||||
)
|
|
||||||
setSubmission(savedSubmission);
|
|
||||||
setSort(String(savedSort || "date_created"));
|
|
||||||
setDirection(String(savedDirection || "desc"));
|
|
||||||
if (typeof limit === "number") setPageSize(limit);
|
|
||||||
const next = [
|
|
||||||
...new Set([
|
|
||||||
"name",
|
|
||||||
...view.columns.filter((c) => c in columnLabels),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
setVisibleColumns(next);
|
|
||||||
localStorage.setItem("alpha-columns", JSON.stringify(next));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<form
|
<form
|
||||||
className="filter-panel"
|
className="filter-panel"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -592,12 +553,6 @@ export function AlphaPage({
|
|||||||
optionList={stateOptions}
|
optionList={stateOptions}
|
||||||
onChange={(v) => updateDraft("research_state", v)}
|
onChange={(v) => updateDraft("research_state", v)}
|
||||||
/>
|
/>
|
||||||
<Button htmlType="submit" theme="solid">
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<details className="advanced-filters">
|
<details className="advanced-filters">
|
||||||
<summary>
|
<summary>
|
||||||
@@ -724,7 +679,55 @@ export function AlphaPage({
|
|||||||
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
<div className="inline-actions filter-actions">
|
||||||
|
<Button htmlType="submit" theme="solid">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<SavedViews
|
||||||
|
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
|
||||||
|
columns={visibleColumns}
|
||||||
|
onRestore={(view) => {
|
||||||
|
const {
|
||||||
|
submission: savedSubmission,
|
||||||
|
sort: savedSort,
|
||||||
|
direction: savedDirection,
|
||||||
|
limit,
|
||||||
|
offset: _offset,
|
||||||
|
...rest
|
||||||
|
} = view.filters;
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
Object.entries(rest)
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined)
|
||||||
|
.map(([k, v]) => [k, String(v)]),
|
||||||
|
);
|
||||||
|
setFilters(values);
|
||||||
|
setDraft(values);
|
||||||
|
setPage(1);
|
||||||
|
setSelected([]);
|
||||||
|
if (
|
||||||
|
savedSubmission === "SUBMITTED" ||
|
||||||
|
savedSubmission === "UNSUBMITTED"
|
||||||
|
)
|
||||||
|
setSubmission(savedSubmission);
|
||||||
|
setSort(String(savedSort || "date_created"));
|
||||||
|
setDirection(String(savedDirection || "desc"));
|
||||||
|
if (typeof limit === "number") setPageSize(limit);
|
||||||
|
const next = [
|
||||||
|
...new Set([
|
||||||
|
"name",
|
||||||
|
...view.columns.filter((c) => c in columnLabels),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
setVisibleColumns(next);
|
||||||
|
localStorage.setItem("alpha-columns", JSON.stringify(next));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>
|
<strong>
|
||||||
|
|||||||
@@ -981,24 +981,6 @@ export function DatasetPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
loading={busy}
|
|
||||||
disabled={!scopeValid}
|
|
||||||
onClick={() => void sync()}
|
|
||||||
>
|
|
||||||
同步目录
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
remember();
|
|
||||||
setHistoryOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已保存输入 ({drafts.length})
|
|
||||||
</Button>
|
|
||||||
<span className="muted">
|
|
||||||
{formatTime(data.synced_at, account?.timezone)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{scopeError && (
|
{scopeError && (
|
||||||
<Banner
|
<Banner
|
||||||
@@ -1041,9 +1023,27 @@ export function DatasetPage({
|
|||||||
optionList={options(data.categories[browse.category] ?? [])}
|
optionList={options(data.categories[browse.category] ?? [])}
|
||||||
onChange={(v) => editBrowse({ subcategory: String(v ?? "") })}
|
onChange={(v) => editBrowse({ subcategory: String(v ?? "") })}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="catalog-tools">
|
<div className="catalog-tools">
|
||||||
|
<Button
|
||||||
|
loading={busy}
|
||||||
|
disabled={!scopeValid}
|
||||||
|
onClick={() => void sync()}
|
||||||
|
>
|
||||||
|
同步目录
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
remember();
|
||||||
|
setHistoryOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已保存输入 ({drafts.length})
|
||||||
|
</Button>
|
||||||
|
<span className="muted">
|
||||||
|
{formatTime(data.synced_at, account?.timezone)}
|
||||||
|
</span>
|
||||||
|
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
|
||||||
<span aria-live="polite">{summary}</span>
|
<span aria-live="polite">{summary}</span>
|
||||||
{restoreAction}
|
{restoreAction}
|
||||||
{inputAction}
|
{inputAction}
|
||||||
@@ -1075,24 +1075,11 @@ export function DatasetPage({
|
|||||||
{...panelProps}
|
{...panelProps}
|
||||||
className="catalog-sheet catalog-fields"
|
className="catalog-sheet catalog-fields"
|
||||||
visible={fieldsOpen && !suspended && active}
|
visible={fieldsOpen && !suspended && active}
|
||||||
width={viewport < 900 ? viewport : viewport * 0.75}
|
width="60%"
|
||||||
mask={top === "fields"}
|
mask={top === "fields"}
|
||||||
>
|
>
|
||||||
<div className="catalog-layer" ref={fieldsRef}>
|
<div className="catalog-layer" ref={fieldsRef}>
|
||||||
{fieldError && <Banner type="danger" description={fieldError} />}
|
{fieldError && <Banner type="danger" description={fieldError} />}
|
||||||
<div className="catalog-tools">
|
|
||||||
<Button onClick={onChat}>AI 助手</Button>
|
|
||||||
<Button aria-label="关闭字段列表" onClick={close}>
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
<span>{summary}</span>
|
|
||||||
{restoreAction}
|
|
||||||
{inputAction}
|
|
||||||
<Button loading={busy} onClick={() => void sync(selected?.id)}>
|
|
||||||
刷新字段
|
|
||||||
</Button>
|
|
||||||
{jobStatus}
|
|
||||||
</div>
|
|
||||||
<div className="catalog-tools">
|
<div className="catalog-tools">
|
||||||
<span>
|
<span>
|
||||||
{scope.region} · {scope.universe} · Delay {scope.delay}
|
{scope.region} · {scope.universe} · Delay {scope.delay}
|
||||||
@@ -1130,9 +1117,22 @@ export function DatasetPage({
|
|||||||
editBrowse({ coverage_min: String(v ?? "") }, true)
|
editBrowse({ coverage_min: String(v ?? "") }, true)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="catalog-tools">
|
||||||
<Button onClick={() => setFieldBrowse(initialBrowse)}>
|
<Button onClick={() => setFieldBrowse(initialBrowse)}>
|
||||||
重置筛选
|
重置筛选
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={onChat}>AI 助手</Button>
|
||||||
|
<Button aria-label="关闭字段列表" onClick={close}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<span>{summary}</span>
|
||||||
|
{restoreAction}
|
||||||
|
{inputAction}
|
||||||
|
<Button loading={busy} onClick={() => void sync(selected?.id)}>
|
||||||
|
刷新字段
|
||||||
|
</Button>
|
||||||
|
{jobStatus}
|
||||||
</div>
|
</div>
|
||||||
<Table<Entry>
|
<Table<Entry>
|
||||||
className="alpha-table catalog-table"
|
className="alpha-table catalog-table"
|
||||||
@@ -1161,11 +1161,7 @@ export function DatasetPage({
|
|||||||
zIndex={1101}
|
zIndex={1101}
|
||||||
className="catalog-sheet catalog-detail"
|
className="catalog-sheet catalog-detail"
|
||||||
visible={!!detail && !suspended && active}
|
visible={!!detail && !suspended && active}
|
||||||
width={
|
width="60%"
|
||||||
detail?.field && viewport >= 900
|
|
||||||
? viewport * 0.3
|
|
||||||
: Math.min(560, viewport)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="catalog-layer" ref={detailRef}>
|
<div className="catalog-layer" ref={detailRef}>
|
||||||
<div className="catalog-tools">
|
<div className="catalog-tools">
|
||||||
@@ -1288,7 +1284,7 @@ export function DatasetPage({
|
|||||||
zIndex={1102}
|
zIndex={1102}
|
||||||
className="catalog-sheet"
|
className="catalog-sheet"
|
||||||
visible={inputOpen && !suspended && active}
|
visible={inputOpen && !suspended && active}
|
||||||
width={Math.min(560, viewport)}
|
width="60%"
|
||||||
>
|
>
|
||||||
<div className="catalog-layer" ref={inputRef}>
|
<div className="catalog-layer" ref={inputRef}>
|
||||||
<div className="catalog-tools">
|
<div className="catalog-tools">
|
||||||
@@ -1349,7 +1345,7 @@ export function DatasetPage({
|
|||||||
zIndex={1103}
|
zIndex={1103}
|
||||||
className="catalog-sheet"
|
className="catalog-sheet"
|
||||||
visible={historyOpen && !suspended && active}
|
visible={historyOpen && !suspended && active}
|
||||||
width={Math.min(560, viewport)}
|
width="60%"
|
||||||
>
|
>
|
||||||
<div className="catalog-layer" ref={historyRef}>
|
<div className="catalog-layer" ref={historyRef}>
|
||||||
<div className="catalog-tools">
|
<div className="catalog-tools">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ResearchList, ResearchDetail } from "./ResearchList";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
@@ -41,6 +42,7 @@ export function FeaturesPage({
|
|||||||
onAction: (action: UIAction) => void;
|
onAction: (action: UIAction) => void;
|
||||||
onContext: (context: PageContext) => void;
|
onContext: (context: PageContext) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [draft, setDraft] = useState<Feature>(blank);
|
const [draft, setDraft] = useState<Feature>(blank);
|
||||||
const [asset, setAsset] = useState<FeatureAsset | null>(null);
|
const [asset, setAsset] = useState<FeatureAsset | null>(null);
|
||||||
const [items, setItems] = useState<FeatureAsset[]>([]);
|
const [items, setItems] = useState<FeatureAsset[]>([]);
|
||||||
@@ -95,6 +97,7 @@ export function FeaturesPage({
|
|||||||
);
|
);
|
||||||
}, [active, action]);
|
}, [active, action]);
|
||||||
async function load(item: FeatureAsset) {
|
async function load(item: FeatureAsset) {
|
||||||
|
setDetailOpen(true);
|
||||||
setAsset(item);
|
setAsset(item);
|
||||||
setDraft(item.content);
|
setDraft(item.content);
|
||||||
setVersions(await api(`/research/assets/${item.id}/versions`));
|
setVersions(await api(`/research/assets/${item.id}/versions`));
|
||||||
@@ -127,27 +130,12 @@ export function FeaturesPage({
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="research-workspace">
|
<section className="research-workspace research-list-page">
|
||||||
<div className="section-toolbar">
|
{error && !detailOpen && <Banner type="danger" description={error} />}
|
||||||
<div>
|
<ResearchList
|
||||||
<h2>特征工程</h2>
|
resetKey={JSON.stringify([q, page])}
|
||||||
<p className="muted">
|
label="特征方案"
|
||||||
围绕固定字段记录处理步骤与经济假设,再进入模板、候选和回测闭环。
|
filters={
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setAsset(null);
|
|
||||||
setDraft(blank());
|
|
||||||
setVersions([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
新建特征方案
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error && <Banner type="danger" description={error} />}
|
|
||||||
<div className="research-layout">
|
|
||||||
<aside className="research-library">
|
|
||||||
<Input
|
<Input
|
||||||
aria-label="搜索特征方案"
|
aria-label="搜索特征方案"
|
||||||
placeholder="搜索方案"
|
placeholder="搜索方案"
|
||||||
@@ -157,24 +145,76 @@ export function FeaturesPage({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{items.map((item) => (
|
}
|
||||||
<Button
|
actions={
|
||||||
key={item.id}
|
<Button
|
||||||
block
|
theme="solid"
|
||||||
theme={asset?.id === item.id ? "light" : "borderless"}
|
onClick={() => {
|
||||||
onClick={() => void run("load", () => load(item))}
|
setAsset(null);
|
||||||
>
|
setDraft(blank());
|
||||||
{item.name} · v{item.version}
|
setVersions([]);
|
||||||
</Button>
|
setDetailOpen(true);
|
||||||
))}
|
}}
|
||||||
<Pagination
|
>
|
||||||
size="small"
|
新建特征方案
|
||||||
currentPage={page}
|
</Button>
|
||||||
pageSize={25}
|
}
|
||||||
total={total}
|
pagination={
|
||||||
onPageChange={setPage}
|
<>
|
||||||
/>
|
<span>共 {total} 个方案</span>
|
||||||
</aside>
|
{total > 0 && (
|
||||||
|
<Pagination
|
||||||
|
size="small"
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={25}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<table className="research-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>方案名称</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>经济假设</th>
|
||||||
|
<th>固定输入</th>
|
||||||
|
<th>处理步骤</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() => void run("load", () => load(item))}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td>v{item.version}</td>
|
||||||
|
<td>{item.content.hypothesis || "—"}</td>
|
||||||
|
<td>{item.content.input_ids.length}</td>
|
||||||
|
<td>{item.content.steps.length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!items.length && (
|
||||||
|
<p className="research-list-empty">
|
||||||
|
暂无特征方案,请新建方案或调整筛选条件。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ResearchList>
|
||||||
|
<ResearchDetail
|
||||||
|
title={asset ? asset.name : "新建特征方案"}
|
||||||
|
visible={active && detailOpen}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
>
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
<div className="research-main">
|
<div className="research-main">
|
||||||
<div className="inline-actions">
|
<div className="inline-actions">
|
||||||
{asset && (
|
{asset && (
|
||||||
@@ -380,7 +420,7 @@ export function FeaturesPage({
|
|||||||
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResearchDetail>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ResearchList, ResearchDetail } from "./ResearchList";
|
||||||
import { ResearchSelect } from "./ResearchSelect";
|
import { ResearchSelect } from "./ResearchSelect";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -61,94 +62,65 @@ export function OperatorsPage({ active }: { active: boolean }) {
|
|||||||
if (active) void task(load);
|
if (active) void task(load);
|
||||||
}, [active, query, category, favorite, offset]);
|
}, [active, query, category, favorite, offset]);
|
||||||
return (
|
return (
|
||||||
<div className="research-workspace">
|
<div className="research-workspace research-list-page">
|
||||||
<header className="research-page-heading">
|
{error && !selected && <Banner type="danger" description={error} />}
|
||||||
<div>
|
<ResearchList
|
||||||
<h2>算子库</h2>
|
resetKey={JSON.stringify([query, category, favorite, offset])}
|
||||||
<p>
|
label="算子库"
|
||||||
平台定义只读,本地收藏和研究备注独立保存。同步时间:
|
filters={
|
||||||
{formatTime(result.fetched_at)}
|
<div className="research-toolbar">
|
||||||
</p>
|
<Input
|
||||||
</div>
|
aria-label="搜索算子"
|
||||||
<Button
|
placeholder="名称、参数或描述"
|
||||||
theme="solid"
|
value={query}
|
||||||
loading={busy}
|
onChange={(value) => {
|
||||||
onClick={() =>
|
setQuery(value);
|
||||||
void task(async () => {
|
setOffset(0);
|
||||||
await post("/catalog/operators/refresh");
|
}}
|
||||||
await load();
|
/>
|
||||||
})
|
<ResearchSelect
|
||||||
}
|
label="算子分类"
|
||||||
>
|
value={category}
|
||||||
同步算子目录
|
optionList={[
|
||||||
</Button>
|
{ value: "", label: "全部分类" },
|
||||||
</header>
|
...result.categories.map((value) => ({ value, label: value })),
|
||||||
{error && <Banner type="danger" description={error} />}
|
]}
|
||||||
<div className="research-toolbar">
|
onChange={(value) => {
|
||||||
<Input
|
setCategory(value as string);
|
||||||
aria-label="搜索算子"
|
setOffset(0);
|
||||||
placeholder="名称、参数或描述"
|
}}
|
||||||
value={query}
|
/>
|
||||||
onChange={(value) => {
|
<Checkbox
|
||||||
setQuery(value);
|
checked={favorite}
|
||||||
setOffset(0);
|
onChange={(event) => {
|
||||||
}}
|
setFavorite(!!event.target.checked);
|
||||||
/>
|
setOffset(0);
|
||||||
<ResearchSelect
|
}}
|
||||||
label="算子分类"
|
>
|
||||||
value={category}
|
只看收藏
|
||||||
optionList={[
|
</Checkbox>
|
||||||
{ value: "", label: "全部分类" },
|
</div>
|
||||||
...result.categories.map((value) => ({ value, label: value })),
|
}
|
||||||
]}
|
actions={
|
||||||
onChange={(value) => {
|
<>
|
||||||
setCategory(value as string);
|
<Button
|
||||||
setOffset(0);
|
theme="solid"
|
||||||
}}
|
loading={busy}
|
||||||
/>
|
onClick={() =>
|
||||||
<Checkbox
|
void task(async () => {
|
||||||
checked={favorite}
|
await post("/catalog/operators/refresh");
|
||||||
onChange={(event) => {
|
await load();
|
||||||
setFavorite(!!event.target.checked);
|
})
|
||||||
setOffset(0);
|
}
|
||||||
}}
|
>
|
||||||
>
|
同步算子目录
|
||||||
只看收藏
|
</Button>
|
||||||
</Checkbox>
|
<span className="muted">
|
||||||
</div>
|
同步时间:{formatTime(result.fetched_at)}
|
||||||
<div className="research-operator-layout">
|
</span>
|
||||||
<section className="research-card">
|
</>
|
||||||
<table className="research-table">
|
}
|
||||||
<thead>
|
pagination={
|
||||||
<tr>
|
|
||||||
<th>算子</th>
|
|
||||||
<th>分类</th>
|
|
||||||
<th>定义</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.items.map((item) => (
|
|
||||||
<tr key={item.name}>
|
|
||||||
<td>
|
|
||||||
<Button
|
|
||||||
theme="borderless"
|
|
||||||
onClick={() => setSelected(item)}
|
|
||||||
>
|
|
||||||
{item.local.favorite ? "已收藏 · " : ""}
|
|
||||||
{item.name}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
<td>{item.category}</td>
|
|
||||||
<td>
|
|
||||||
<code>{item.definition}</code>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{!result.items.length && (
|
|
||||||
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
|
|
||||||
)}
|
|
||||||
<div className="research-toolbar">
|
<div className="research-toolbar">
|
||||||
<span>共 {result.total} 个</span>
|
<span>共 {result.total} 个</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -164,7 +136,43 @@ export function OperatorsPage({ active }: { active: boolean }) {
|
|||||||
下一页
|
下一页
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
}
|
||||||
|
>
|
||||||
|
<table className="research-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>算子</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>定义</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.items.map((item) => (
|
||||||
|
<tr key={item.name}>
|
||||||
|
<td>
|
||||||
|
<Button theme="borderless" onClick={() => setSelected(item)}>
|
||||||
|
{item.local.favorite ? "已收藏 · " : ""}
|
||||||
|
{item.name}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td>{item.category}</td>
|
||||||
|
<td>
|
||||||
|
<code>{item.definition}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!result.items.length && (
|
||||||
|
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
|
||||||
|
)}
|
||||||
|
</ResearchList>
|
||||||
|
<ResearchDetail
|
||||||
|
title={selected?.name || "算子详情"}
|
||||||
|
visible={active && !!selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
{selected && (
|
{selected && (
|
||||||
<section className="research-card">
|
<section className="research-card">
|
||||||
<h3>{selected.name}</h3>
|
<h3>{selected.name}</h3>
|
||||||
@@ -225,7 +233,7 @@ export function OperatorsPage({ active }: { active: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ResearchDetail>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ResearchList, ResearchDetail } from "./ResearchList";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
|
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
|
||||||
import { api, formatTime } from "../api";
|
import { api, formatTime } from "../api";
|
||||||
@@ -23,7 +24,8 @@ export function PipelinePage({
|
|||||||
localStorage.getItem("research-selected-flow"),
|
localStorage.getItem("research-selected-flow"),
|
||||||
);
|
);
|
||||||
const [run, setRun] = useState<FlowRun | null>(null);
|
const [run, setRun] = useState<FlowRun | null>(null);
|
||||||
const [creating, setCreating] = useState(!selected);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,46 +61,78 @@ export function PipelinePage({
|
|||||||
};
|
};
|
||||||
}, [active, page, selected, revision]);
|
}, [active, page, selected, revision]);
|
||||||
function select(id: string) {
|
function select(id: string) {
|
||||||
|
setRun(null);
|
||||||
|
setDetailOpen(true);
|
||||||
setSelected(id);
|
setSelected(id);
|
||||||
localStorage.setItem("research-selected-flow", id);
|
localStorage.setItem("research-selected-flow", id);
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="research-workspace">
|
<section className="research-workspace research-list-page">
|
||||||
<div className="section-toolbar">
|
{error && !detailOpen && <Banner type="danger" description={error} />}
|
||||||
<div>
|
<ResearchList
|
||||||
<h2>研究流水线</h2>
|
resetKey={JSON.stringify([page])}
|
||||||
<p className="muted">
|
label="研究流水线"
|
||||||
生成 → 校验与设参 → 回测 → 评估决策 → 增强 → 重新展开
|
actions={
|
||||||
</p>
|
<Button
|
||||||
</div>
|
theme="solid"
|
||||||
<Button onClick={() => setCreating(true)}>新建自动研究</Button>
|
onClick={() => {
|
||||||
</div>
|
setCreating(true);
|
||||||
{error && <Banner type="danger" description={error} />}
|
setDetailOpen(true);
|
||||||
<div className="research-layout">
|
}}
|
||||||
<aside className="research-library">
|
>
|
||||||
<h3>研究运行</h3>
|
新建自动研究
|
||||||
{items.map((item) => (
|
</Button>
|
||||||
<button
|
}
|
||||||
className={`research-library-item ${selected === item.id ? "selected" : ""}`}
|
pagination={
|
||||||
key={item.id}
|
<>
|
||||||
onClick={() => select(item.id)}
|
<span>共 {total} 次运行</span>
|
||||||
>
|
{total > 0 && (
|
||||||
<strong>{item.name}</strong>
|
<Pagination
|
||||||
<span>
|
size="small"
|
||||||
{flowStatus[item.status]} · 第 {item.round} 轮
|
total={total}
|
||||||
</span>
|
currentPage={page}
|
||||||
<span>{formatTime(item.created_at)}</span>
|
pageSize={25}
|
||||||
</button>
|
onPageChange={setPage}
|
||||||
))}
|
/>
|
||||||
<Pagination
|
)}
|
||||||
size="small"
|
</>
|
||||||
total={total}
|
}
|
||||||
currentPage={page}
|
>
|
||||||
pageSize={25}
|
<table className="research-table">
|
||||||
onPageChange={setPage}
|
<thead>
|
||||||
/>
|
<tr>
|
||||||
</aside>
|
<th>研究名称</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>轮次</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<Button theme="borderless" onClick={() => select(item.id)}>
|
||||||
|
{item.name}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td>{flowStatus[item.status]}</td>
|
||||||
|
<td>{item.round}</td>
|
||||||
|
<td>{formatTime(item.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!items.length && (
|
||||||
|
<p className="research-list-empty">暂无研究运行,请新建自动研究。</p>
|
||||||
|
)}
|
||||||
|
</ResearchList>
|
||||||
|
<ResearchDetail
|
||||||
|
title={creating ? "新建自动研究" : "研究运行详情"}
|
||||||
|
visible={active && detailOpen}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
>
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
<div className="research-main">
|
<div className="research-main">
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<FlowLaunchForm
|
<FlowLaunchForm
|
||||||
@@ -119,7 +153,7 @@ export function PipelinePage({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResearchDetail>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { SideSheet } from "@douyinfe/semi-ui-19";
|
||||||
|
|
||||||
|
/** Parent supplies a bounded flex area; only the data viewport owns scrolling. */
|
||||||
|
export function ResearchList({
|
||||||
|
label,
|
||||||
|
resetKey,
|
||||||
|
filters,
|
||||||
|
actions,
|
||||||
|
pagination,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
resetKey: string;
|
||||||
|
filters?: ReactNode;
|
||||||
|
actions: ReactNode;
|
||||||
|
pagination: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
viewport.current?.scrollTo({ top: 0, left: 0 });
|
||||||
|
}, [resetKey]);
|
||||||
|
return (
|
||||||
|
<section className="research-list" aria-label={label}>
|
||||||
|
{filters && <div className="research-list-filters">{filters}</div>}
|
||||||
|
<div className="research-list-actions">{actions}</div>
|
||||||
|
<div
|
||||||
|
ref={viewport}
|
||||||
|
className="research-list-scroll"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${label}数据区`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="table-pagination">{pagination}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep drafts mounted while closed; inactive workspace pages must hide portals. */
|
||||||
|
export function ResearchDetail({
|
||||||
|
title,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
title={title}
|
||||||
|
className="research-detail"
|
||||||
|
placement="right"
|
||||||
|
width="60%"
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
closeOnEsc
|
||||||
|
keepDOM
|
||||||
|
motion={false}
|
||||||
|
>
|
||||||
|
<div className="research-detail-content">{children}</div>
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,73 +1,25 @@
|
|||||||
.research-workspace {
|
.research-workspace {
|
||||||
height: 100%;
|
display: flex;
|
||||||
overflow: auto;
|
flex-direction: column;
|
||||||
padding: 24px 28px;
|
flex: 1;
|
||||||
color: var(--semi-color-text-0);
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--semi-color-text-0);
|
||||||
}
|
}
|
||||||
.research-page-heading,
|
|
||||||
.research-section-heading {
|
.research-section-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.research-page-heading {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.research-page-heading h2 {
|
|
||||||
font-size: 22px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
.research-workspace p,
|
.research-workspace p,
|
||||||
|
.research-detail-content p,
|
||||||
.research-hint {
|
.research-hint {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--semi-color-text-2);
|
color: var(--semi-color-text-2);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.research-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.research-library {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
max-height: calc(100vh - 180px);
|
|
||||||
overflow: auto;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
|
||||||
.research-library h3,
|
|
||||||
.research-card h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
.research-library > h3 {
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
.research-library-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
.research-library-item.selected,
|
|
||||||
.research-library-item:hover {
|
|
||||||
background: var(--semi-color-fill-0);
|
|
||||||
}
|
|
||||||
.research-library-item span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--semi-color-text-2);
|
|
||||||
}
|
|
||||||
.research-main {
|
.research-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -80,6 +32,10 @@
|
|||||||
background: var(--semi-color-bg-1);
|
background: var(--semi-color-bg-1);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.research-card h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
.research-card label,
|
.research-card label,
|
||||||
.research-editor label {
|
.research-editor label {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -127,13 +83,15 @@
|
|||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
.research-code-editor textarea,
|
.research-code-editor textarea,
|
||||||
.research-workspace code {
|
.research-workspace code,
|
||||||
|
.research-detail-content code {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.research-workspace pre {
|
.research-workspace pre,
|
||||||
|
.research-detail-content pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -170,19 +128,16 @@
|
|||||||
.research-table details {
|
.research-table details {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.research-workspace summary {
|
.research-workspace summary,
|
||||||
|
.research-detail-content summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.research-comparison-grid,
|
.research-comparison-grid {
|
||||||
.research-operator-layout {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.research-operator-layout {
|
|
||||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
|
|
||||||
}
|
|
||||||
.research-comparison-grid article {
|
.research-comparison-grid article {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
@@ -192,31 +147,10 @@
|
|||||||
padding: 18px 12px 8px;
|
padding: 18px 12px 8px;
|
||||||
}
|
}
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.research-layout {
|
|
||||||
grid-template-columns: 170px minmax(0, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.research-workspace {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.research-operator-layout,
|
|
||||||
.research-comparison-grid {
|
.research-comparison-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
|
||||||
.research-layout {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.research-library {
|
|
||||||
position: static;
|
|
||||||
max-height: 200px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.research-page-heading {
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-sr-only {
|
.research-sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -381,3 +315,77 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lists inherit the viewport boundary; controls and pagination never join data scrolling. */
|
||||||
|
.research-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.research-list-filters,
|
||||||
|
.research-list-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.research-list-filters {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.research-list-filters > .semi-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
.research-list-filters > .research-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.research-list .research-toolbar {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.research-list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.research-list-scroll .research-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
.research-list-empty {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.research-detail .semi-sidesheet-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.research-detail-content {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.research-detail-content .research-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.quantflow-detail-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.quantflow-detail-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,6 +240,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
|
|||||||
.primary-filters > .semi-select {
|
.primary-filters > .semi-select {
|
||||||
width: 136px;
|
width: 136px;
|
||||||
}
|
}
|
||||||
|
.filter-actions {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
.advanced-filters {
|
.advanced-filters {
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type AlphaDetail = Alpha & {
|
|||||||
export type Account = {
|
export type Account = {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
credentials_source?: "environment" | "database";
|
||||||
wq_user_id: string | null;
|
wq_user_id: string | null;
|
||||||
profile: Record<string, unknown>;
|
profile: Record<string, unknown>;
|
||||||
connection_status: string;
|
connection_status: string;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ test("目录筛选、双层详情、完整输入、排除、备注与刷新", as
|
|||||||
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
||||||
await expect(fields).toBeVisible();
|
await expect(fields).toBeVisible();
|
||||||
await expect(fields).toContainText("全部 123 个字段");
|
await expect(fields).toContainText("全部 123 个字段");
|
||||||
expect((await fields.boundingBox())!.width).toBeCloseTo(1080, 0);
|
expect((await fields.boundingBox())!.width).toBeCloseTo(864, 0);
|
||||||
await page.getByLabel("搜索字段").fill("字段 12");
|
await page.getByLabel("搜索字段").fill("字段 12");
|
||||||
await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount(
|
await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount(
|
||||||
3,
|
3,
|
||||||
@@ -99,7 +99,7 @@ test("目录筛选、双层详情、完整输入、排除、备注与刷新", as
|
|||||||
.click();
|
.click();
|
||||||
const detail = page.getByRole("dialog", { name: "字段详情", exact: true });
|
const detail = page.getByRole("dialog", { name: "字段详情", exact: true });
|
||||||
await expect(detail).toContainText("FUTURE_TYPE");
|
await expect(detail).toContainText("FUTURE_TYPE");
|
||||||
expect((await detail.boundingBox())!.width).toBeCloseTo(432, 0);
|
expect((await detail.boundingBox())!.width).toBeCloseTo(864, 0);
|
||||||
await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设");
|
await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设");
|
||||||
await page.getByRole("button", { name: "保存备注", exact: true }).click();
|
await page.getByRole("button", { name: "保存备注", exact: true }).click();
|
||||||
await expect(page.getByText("研究备注已保存")).toBeVisible();
|
await expect(page.getByText("研究备注已保存")).toBeVisible();
|
||||||
@@ -173,7 +173,7 @@ test("窄屏抽屉、键盘隔离和研究范围切换", async ({ page }) => {
|
|||||||
await openFields(page);
|
await openFields(page);
|
||||||
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
|
||||||
await expect(fields).toBeVisible();
|
await expect(fields).toBeVisible();
|
||||||
expect((await fields.boundingBox())!.width).toBeCloseTo(390, 0);
|
expect((await fields.boundingBox())!.width).toBeCloseTo(234, 0);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "TEST 字段 000", exact: true })
|
.getByRole("button", { name: "TEST 字段 000", exact: true })
|
||||||
.click();
|
.click();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
|
|||||||
.getByRole("button", { name: "QuantFlow", exact: true })
|
.getByRole("button", { name: "QuantFlow", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "QuantFlow", exact: true }),
|
page.getByRole("region", { name: "QuantFlow", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.request.put("/api/v1/account/credentials", {
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
headers,
|
headers,
|
||||||
@@ -114,6 +114,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
|
|||||||
})
|
})
|
||||||
).json();
|
).json();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "新建流程", exact: true }).click();
|
||||||
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究");
|
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究");
|
||||||
async function add(kind: string, label: string) {
|
async function add(kind: string, label: string) {
|
||||||
await page.getByLabel("新增节点类型").selectOption(kind);
|
await page.getByLabel("新增节点类型").selectOption(kind);
|
||||||
@@ -179,7 +180,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
|
|||||||
.getByRole("option")
|
.getByRole("option")
|
||||||
.filter({ hasText: fixed.id.slice(0, 8) })
|
.filter({ hasText: fixed.id.slice(0, 8) })
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("heading", { name: "QuantFlow", exact: true }).click();
|
await page.getByLabel("自动研究假设").click();
|
||||||
for (const [label, value] of [
|
for (const [label, value] of [
|
||||||
["最大轮数", "1"],
|
["最大轮数", "1"],
|
||||||
["最大模拟条目数", "2"],
|
["最大模拟条目数", "2"],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
|
|||||||
.getByRole("button", { name: "研究流水线", exact: true })
|
.getByRole("button", { name: "研究流水线", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "研究流水线", exact: true }),
|
page.getByRole("region", { name: "研究流水线", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.request.put("/api/v1/account/credentials", {
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
headers,
|
headers,
|
||||||
@@ -94,6 +94,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
|
|||||||
).ok(),
|
).ok(),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "新建自动研究", exact: true }).click();
|
||||||
await page.getByLabel("自动研究名称").fill("浏览器有限研究");
|
await page.getByLabel("自动研究名称").fill("浏览器有限研究");
|
||||||
await page.getByLabel("自动研究假设").fill("研究排名稳定性");
|
await page.getByLabel("自动研究假设").fill("研究排名稳定性");
|
||||||
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
|
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
|
||||||
@@ -101,7 +102,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
|
|||||||
.getByRole("option")
|
.getByRole("option")
|
||||||
.filter({ hasText: fixed.id.slice(0, 8) })
|
.filter({ hasText: fixed.id.slice(0, 8) })
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("heading", { name: "研究流水线", exact: true }).click();
|
await page.getByLabel("自动研究名称").click();
|
||||||
for (const [label, value] of [
|
for (const [label, value] of [
|
||||||
["最大轮数", "1"],
|
["最大轮数", "1"],
|
||||||
["最大模拟条目数", "2"],
|
["最大模拟条目数", "2"],
|
||||||
@@ -110,16 +111,16 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
|
|||||||
])
|
])
|
||||||
await page.getByLabel(label, { exact: true }).fill(value);
|
await page.getByLabel(label, { exact: true }).fill(value);
|
||||||
await page.getByRole("button", { name: "核对并启动研究" }).click();
|
await page.getByRole("button", { name: "核对并启动研究" }).click();
|
||||||
await expect(page.getByRole("dialog", {name: "确认自动研究授权"})).toContainText(
|
await expect(
|
||||||
"最多 1 轮、2 条模拟、2 次模型调用",
|
page.getByRole("dialog", { name: "确认自动研究授权" }),
|
||||||
);
|
).toContainText("最多 1 轮、2 条模拟、2 次模型调用");
|
||||||
const started = page.waitForResponse(
|
const started = page.waitForResponse(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.url().endsWith("/research/flows/runs") &&
|
r.url().endsWith("/research/flows/runs") &&
|
||||||
r.request().method() === "POST",
|
r.request().method() === "POST",
|
||||||
);
|
);
|
||||||
await page
|
await page
|
||||||
.getByRole("dialog", {name: "确认自动研究授权"})
|
.getByRole("dialog", { name: "确认自动研究授权" })
|
||||||
.getByRole("button", { name: "confirm", exact: true })
|
.getByRole("button", { name: "confirm", exact: true })
|
||||||
.click();
|
.click();
|
||||||
const response = await started;
|
const response = await started;
|
||||||
@@ -143,6 +144,9 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
|
|||||||
expect(completed.model_calls_used).toBe(2);
|
expect(completed.model_calls_used).toBe(2);
|
||||||
expect(completed.steps).toHaveLength(8);
|
expect(completed.steps).toHaveLength(8);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "浏览器有限研究", exact: true })
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("region", { name: "研究运行详情" }),
|
page.getByRole("region", { name: "研究运行详情" }),
|
||||||
).toContainText("浏览器有限研究");
|
).toContainText("浏览器有限研究");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
|
|||||||
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
await page.getByRole("button", { name: "进入工作空间" }).click();
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "特征工程", exact: true }),
|
page.getByRole("button", { name: "新建特征方案", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.request.put("/api/v1/account/credentials", {
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
headers,
|
headers,
|
||||||
@@ -62,6 +62,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
|
|||||||
).json();
|
).json();
|
||||||
expect(fixed.id).toBeTruthy();
|
expect(fixed.id).toBeTruthy();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "新建特征方案", exact: true }).click();
|
||||||
await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收");
|
await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收");
|
||||||
await page
|
await page
|
||||||
.getByLabel("特征经济假设", { exact: true })
|
.getByLabel("特征经济假设", { exact: true })
|
||||||
@@ -71,7 +72,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
|
|||||||
.getByRole("option")
|
.getByRole("option")
|
||||||
.filter({ hasText: fixed.id.slice(0, 8) })
|
.filter({ hasText: fixed.id.slice(0, 8) })
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("heading", { name: "特征工程", exact: true }).click();
|
await page.getByLabel("特征方案名称", { exact: true }).click();
|
||||||
await page.getByRole("button", { name: "添加处理步骤" }).click();
|
await page.getByRole("button", { name: "添加处理步骤" }).click();
|
||||||
await page.getByLabel("步骤 1 名称").fill("横截面排名");
|
await page.getByLabel("步骤 1 名称").fill("横截面排名");
|
||||||
await page.getByLabel("步骤 1 理由").fill("减少尺度影响");
|
await page.getByLabel("步骤 1 理由").fill("减少尺度影响");
|
||||||
@@ -97,6 +98,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
|
|||||||
expect(templates.items[0].provenance.feature.content.input_ids).toEqual([
|
expect(templates.items[0].provenance.feature.content.input_ids).toEqual([
|
||||||
fixed.id,
|
fixed.id,
|
||||||
]);
|
]);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
|
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
|
||||||
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
|
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
|
||||||
await page.getByRole("button", { name: "保存为新视图" }).click();
|
await page.getByRole("button", { name: "保存为新视图" }).click();
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy on the target Linux Docker host with configuration injected by Gitea.
|
||||||
|
# Returns nonzero on configuration/build/migration/health failure. Never removes data.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
# Fail before touching the host; never read a checkout's local .env file.
|
||||||
|
for key in WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY DATABASE_NETWORK PUBLIC_ORIGIN; do
|
||||||
|
if [[ -z "${!key:-}" ]]; then
|
||||||
|
echo "Missing required Gitea configuration: $key" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
state_dir="${DEPLOY_STATE_DIR:-/opt/wq-alpha}"
|
||||||
|
if [[ "$state_dir" != /* || ! -d "$state_dir" || ! -w "$state_dir" ]]; then
|
||||||
|
echo 'DEPLOY_STATE_DIR must be an existing writable absolute directory.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
command -v flock >/dev/null
|
||||||
|
# Stable across checkouts; stores only a lock and release metadata, never credentials.
|
||||||
|
exec 9>"$state_dir/deploy.lock"
|
||||||
|
flock -n 9 || { echo 'Another production deployment is running.' >&2; exit 1; }
|
||||||
|
export DEPLOY_TAG="${DEPLOY_TAG:-$(git rev-parse HEAD)}"
|
||||||
|
compose=(docker compose --env-file /dev/null -p wq-alpha-production -f compose.production.yaml)
|
||||||
|
trap 'rc=$?; "${compose[@]}" --profile jobs ps -a || true; exit "$rc"' EXIT
|
||||||
|
|
||||||
|
"${compose[@]}" config --quiet
|
||||||
|
"${compose[@]}" --profile jobs config --quiet
|
||||||
|
"${compose[@]}" build backend web
|
||||||
|
# Validate secrets and DB connectivity before interrupting the running version.
|
||||||
|
"${compose[@]}" run --rm --no-deps backend python -c '
|
||||||
|
import asyncio
|
||||||
|
from app.config import Settings
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
async def check():
|
||||||
|
settings = Settings()
|
||||||
|
engine = create_async_engine(settings.database_url)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.execute(text("SELECT 1"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
try:
|
||||||
|
asyncio.run(check())
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit("Production configuration/database preflight failed; check env and database access.") from None
|
||||||
|
'
|
||||||
|
|
||||||
|
# Record immutable image references before switching; do not prune old images.
|
||||||
|
previous_images=$("${compose[@]}" images --quiet)
|
||||||
|
if [[ -n "$previous_images" ]]; then
|
||||||
|
docker image inspect --format '{{.Id}} {{json .RepoTags}}' $previous_images > "$state_dir/previous-images.txt"
|
||||||
|
fi
|
||||||
|
"${compose[@]}" stop web backend
|
||||||
|
"${compose[@]}" --profile jobs run --rm --no-deps migrate
|
||||||
|
"${compose[@]}" up -d --no-build --remove-orphans --wait --wait-timeout 180 backend web
|
||||||
|
printf '%s\n' "$DEPLOY_TAG" > "$state_dir/current-release.txt"
|
||||||
|
echo "Production is healthy; release $DEPLOY_TAG"
|
||||||
@@ -14,6 +14,7 @@ content = "\n".join([
|
|||||||
f"ADMIN_PASSWORD={secrets.token_urlsafe(24)}",
|
f"ADMIN_PASSWORD={secrets.token_urlsafe(24)}",
|
||||||
f"POSTGRES_PASSWORD={secrets.token_hex(24)}",
|
f"POSTGRES_PASSWORD={secrets.token_hex(24)}",
|
||||||
f"ENCRYPTION_KEY={base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}",
|
f"ENCRYPTION_KEY={base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}",
|
||||||
|
"WQ_EMAIL=", "WQ_PASSWORD=",
|
||||||
"LOCAL_PORT=8080", "DOMAIN=alpha.example.com", "",
|
"LOCAL_PORT=8080", "DOMAIN=alpha.example.com", "",
|
||||||
])
|
])
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user