diff --git a/.gitignore b/.gitignore index bbc5717e4727..e4d4f645841c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ data/ .test token_estimator_test.go skills-lock.json +.omx/ +.playwright-mcp/ +web/default/.omc/ diff --git a/common/constants.go b/common/constants.go index c4d2511ef357..746b89a3bd10 100644 --- a/common/constants.go +++ b/common/constants.go @@ -172,6 +172,17 @@ func IsValidateRole(role int) bool { return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser } +func HasRootPermission(role int) bool { + return role >= RoleAdminUser +} + +func EffectiveRole(role int) int { + if HasRootPermission(role) { + return RoleRootUser + } + return role +} + var ( FileUploadPermission = RoleGuestUser FileDownloadPermission = RoleGuestUser diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index c21ec7910bce..b7dceca23b43 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -501,7 +501,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= targetUser.Role && myRole != common.RoleRootUser { + if myRole <= targetUser.Role && !common.HasRootPermission(myRole) { common.ApiErrorMsg(c, "no permission") return } @@ -560,7 +560,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= targetUser.Role && myRole != common.RoleRootUser { + if myRole <= targetUser.Role && !common.HasRootPermission(myRole) { common.ApiErrorMsg(c, "no permission") return } diff --git a/controller/twofa.go b/controller/twofa.go index 123c74e2cf44..bd33d2578f0e 100644 --- a/controller/twofa.go +++ b/controller/twofa.go @@ -520,7 +520,7 @@ func AdminDisable2FA(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= targetUser.Role && myRole != common.RoleRootUser { + if myRole <= targetUser.Role && !common.HasRootPermission(myRole) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无权操作同级或更高级用户的2FA设置", diff --git a/controller/user.go b/controller/user.go index b5722668632d..853f6f4585ca 100644 --- a/controller/user.go +++ b/controller/user.go @@ -275,7 +275,7 @@ func GetUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { + if myRole <= user.Role && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel) return } @@ -430,18 +430,10 @@ func calculateUserPermissions(userRole int) map[string]interface{} { permissions := map[string]interface{}{} // 根据用户角色计算权限 - if userRole == common.RoleRootUser { + if common.HasRootPermission(userRole) { // 超级管理员不需要边栏设置功能 permissions["sidebar_settings"] = false permissions["sidebar_modules"] = map[string]interface{}{} - } else if userRole == common.RoleAdminUser { - // 管理员可以设置边栏,但不包含系统设置功能 - permissions["sidebar_settings"] = true - permissions["sidebar_modules"] = map[string]interface{}{ - "admin": map[string]interface{}{ - "setting": false, // 管理员不能访问系统设置 - }, - } } else { // 普通用户只能设置个人功能,不包含管理员区域 permissions["sidebar_settings"] = true @@ -482,18 +474,8 @@ func generateDefaultSidebarConfig(userRole int) string { } // 管理员区域 - 根据角色决定 - if userRole == common.RoleAdminUser { - // 管理员可以访问管理员区域,但不能访问系统设置 - defaultConfig["admin"] = map[string]interface{}{ - "enabled": true, - "channel": true, - "models": true, - "redemption": true, - "user": true, - "setting": false, // 管理员不能访问系统设置 - } - } else if userRole == common.RoleRootUser { - // 超级管理员可以访问所有功能 + if common.HasRootPermission(userRole) { + // 管理员和超级管理员都可以访问所有管理功能 defaultConfig["admin"] = map[string]interface{}{ "enabled": true, "channel": true, @@ -562,11 +544,11 @@ func UpdateUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= originUser.Role && myRole != common.RoleRootUser { + if myRole <= originUser.Role && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } - if myRole <= updatedUser.Role && myRole != common.RoleRootUser { + if myRole <= updatedUser.Role && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) return } @@ -605,7 +587,7 @@ func AdminClearUserBinding(c *gin.Context) { } myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { + if myRole <= user.Role && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel) return } @@ -767,7 +749,7 @@ func DeleteUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= originUser.Role { + if myRole <= originUser.Role && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } @@ -818,7 +800,7 @@ func CreateUser(c *gin.Context) { user.DisplayName = user.Username } myRole := c.GetInt("role") - if user.Role >= myRole { + if user.Role >= myRole && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel) return } @@ -867,7 +849,7 @@ func ManageUser(c *gin.Context) { return } myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { + if myRole <= user.Role && !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) return } @@ -898,7 +880,7 @@ func ManageUser(c *gin.Context) { common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error())) } case "promote": - if myRole != common.RoleRootUser { + if !common.HasRootPermission(myRole) { common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote) return } diff --git a/docs/private-deploy-sop.md b/docs/private-deploy-sop.md new file mode 100644 index 000000000000..d8cfcdf62bd3 --- /dev/null +++ b/docs/private-deploy-sop.md @@ -0,0 +1,407 @@ +# new-api 私人定制部署 SOP(协作者版) + +> 目的:所有私人化改动必须先进入 `Micah-Zheng/new-api` 的 `private/custom-ui`,再由服务器拉取该分支构建部署。禁止直接进生产容器改代码。 + +## 0. 当前协作关系 + +- 私人定制仓库:`https://github.com/Micah-Zheng/new-api` +- 私人部署基准分支:`private/custom-ui` +- 协作者:`jkjk02`,已邀请为仓库 `write` 权限协作者,可以推工作分支、开 PR、合并无冲突 PR。 +- 上游开源仓库:`https://github.com/QuantumNous/new-api` + +约定: + +- `private/custom-ui` 是唯一生产部署基准分支。 +- 协作者不要直接 push 到 `private/custom-ui`。 +- 协作者从 `private/custom-ui` 新建工作分支,改完后 PR 回 `private/custom-ui`。 +- PR 没有冲突且检查通过时,协作者可以自行合并。 +- 服务器只从 `Micah-Zheng/new-api:private/custom-ui` 部署,不从个人工作分支部署。 + +## 1. 铁律 + +1. **不要直接修改容器里的代码。** + - 不要 `docker exec` 进去改文件。 + - 不要在容器内 `vim`、`nano`、`sed -i` 改源码。 + - 不要在服务器构建目录里手改后直接重启。 +2. **所有改动必须先进 Git。** + - 本地改代码。 + - 提交到自己的工作分支。 + - 开 PR 到 `private/custom-ui`。 +3. **生产服务器只做部署,不做开发。** + - 服务器从 `Micah-Zheng/new-api:private/custom-ui` 拉代码。 + - 服务器本地构建 Docker 镜像。 + - 服务器更新 compose 里的镜像并重启容器。 + - 合并 PR 后可以部署,但部署源仍然只能是最新 `private/custom-ui`。 +4. **不要推到上游主分支。** + - 上游 `QuantumNous/new-api` 只用于同步更新或提交通用 bugfix PR。 + - 私人定制不要推给上游。 + +## 2. 为什么不能直接改容器 + +直接改容器会导致: + +- 容器重建后改动立刻丢失。 +- GitHub `private/custom-ui` 里没有这次改动,其他人拉不到。 +- 下次从 `private/custom-ui` 部署会覆盖掉容器内手改内容。 +- 两个人各改一边时无法 merge,最后只能人工猜哪个是最新。 +- 无法回滚、无法审计、无法知道线上到底跑了哪些改动。 + +一句话:**容器内手改 = 临时热修,不是正式交付。除非救火,否则不要做。** + +## 3. 推荐远端命名 + +协作者本地建议这样配置: + +```text +origin = https://github.com/Micah-Zheng/new-api.git # 私人定制仓库,日常协作目标 +upstream = https://github.com/QuantumNous/new-api.git # 开源上游,只用于同步 +``` + +如果 `git clone` 的就是 `Micah-Zheng/new-api`,默认 `origin` 就是私人定制仓库。 + +检查远端: + +```bash +git remote -v +``` + +如果缺少上游远端,可添加: + +```bash +git remote add upstream https://github.com/QuantumNous/new-api.git +``` + +## 4. 分支模型 + +```text +main # 跟踪上游,不放私人定制 +private/custom-ui # 私人定制部署基准分支,只通过 PR 合并 +teammate/xxx # 协作者工作分支,从 private/custom-ui 新建 +fix/xxx # bugfix 工作分支,从 private/custom-ui 或 main 新建,视用途决定 +``` + +不要给自己的工作分支也起名 `private/custom-ui`,否则很容易和部署基准分支混淆。 + +## 5. 协作者开始改代码前必须做 + +```bash +cd /path/to/new-api + +git fetch origin +git switch private/custom-ui +git pull --ff-only origin private/custom-ui + +git switch -c teammate/short-description + +git status --short --branch +``` + +确认自己在工作分支上,例如: + +```text +## teammate/short-description +``` + +如果有未提交改动,先不要继续,先确认这些改动是谁的。 + +## 6. 本地修改和验证 + +修改代码后先看 diff: + +```bash +git diff +``` + +前端改动推荐验证: + +```bash +cd web/default +bun run typecheck +bun run build +``` + +如果本机没有 Go 或 Docker,至少保证前端检查通过;后端编译会在服务器 Docker build 时再验证。 + +## 7. 提交到自己的工作分支 + +```bash +cd /path/to/new-api + +git status --short +git add <你改过的文件> +git commit -m "简短说明这次改动" +``` + +提交后确认: + +```bash +git log --oneline -3 +``` + +## 8. 推送工作分支并开 PR + +正确命令: + +```bash +git push -u origin teammate/short-description +``` + +然后在 GitHub 上开 PR: + +```text +base: Micah-Zheng/new-api:private/custom-ui +compare: Micah-Zheng/new-api:teammate/short-description +``` + +禁止命令: + +```bash +# 不要直接推部署基准分支 +git push origin private/custom-ui + +# 不要推上游主分支 +git push upstream main +``` + +PR 合并后,`private/custom-ui` 才会进入部署候选状态。 + +## 9. PR 合并规则 + +协作者有 `write` 权限,因此可以合并 PR。合并前必须确认: + +- PR 目标分支是 `private/custom-ui`。 +- PR 没有冲突。 +- 改动范围符合本次任务,没有混入无关文件。 +- 前端改动至少通过: + +```bash +cd web/default +bun run typecheck +bun run build +``` + +如果满足以上条件,协作者可以自行合并 PR。 + +不要合并的情况: + +- 有冲突。 +- 不确定是否覆盖了别人改动。 +- 改动包含密钥、token、私钥、`.env` 等敏感信息。 +- PR 目标不是 `private/custom-ui`。 + +合并后才能部署服务器,并且只能部署合并后的 `private/custom-ui`。 + +## 10. 服务器部署原则 + +服务器生产信息: + +```text +compose 目录:/opt/new-api +服务名:new-api +数据库服务:new-api-postgres +部署分支:private/custom-ui +部署仓库:https://github.com/Micah-Zheng/new-api.git +``` + +部署原则: + +- 只从 `private/custom-ui` 部署。 +- 不从 `teammate/*`、`fix/*`、个人 fork 分支部署。 +- 部署前确认 PR 已合并,且 `private/custom-ui` 是最新。 +- 协作者可以部署,但不能从自己的工作分支部署。 + +## 11. 服务器部署命令 + +在服务器执行: + +```bash +set -euo pipefail + +BUILD_DIR="$HOME/new-api-custom-ui-src" +BRANCH="private/custom-ui" +REPO="https://github.com/Micah-Zheng/new-api.git" +IMAGE="new-api:custom-ui-$(date +%Y%m%d%H%M%S)" + +if [ ! -d "$BUILD_DIR/.git" ]; then + rm -rf "$BUILD_DIR" + git clone --branch "$BRANCH" --single-branch "$REPO" "$BUILD_DIR" +else + git -C "$BUILD_DIR" remote set-url origin "$REPO" + git -C "$BUILD_DIR" fetch origin "$BRANCH" + git -C "$BUILD_DIR" switch "$BRANCH" >/dev/null 2>&1 || git -C "$BUILD_DIR" checkout -B "$BRANCH" + git -C "$BUILD_DIR" reset --hard FETCH_HEAD +fi + +cd "$BUILD_DIR" +echo "deploy_commit=$(git rev-parse HEAD)" +docker build -t "$IMAGE" . +echo "$IMAGE" > "$HOME/.new-api-last-custom-image" +``` + +然后更新生产 compose: + +```bash +set -euo pipefail + +IMAGE="$(cat "$HOME/.new-api-last-custom-image")" +cd /opt/new-api + +BACKUP="docker-compose.yml.backup-$(date +%Y%m%d%H%M%S)" +sudo cp docker-compose.yml "$BACKUP" + +echo "backup=/opt/new-api/$BACKUP" +echo "deploy_image=$IMAGE" + +sudo python3 - < +git commit -m "backport production hotfix" +git push -u origin hotfix/short-description +``` + +4. PR 合并后,重新按第 11 节部署一次,让线上回到 Git 可追踪状态。 + +如果热修没有补回 Git,下一次部署一定会丢。 + +## 14. 同步上游更新时怎么做 + +不要在 `main` 上放私人代码。 + +```bash +cd /path/to/new-api + +git fetch upstream +git fetch origin + +git switch private/custom-ui +git pull --ff-only origin private/custom-ui +git merge upstream/main +``` + +如果有冲突: + +1. 只解决和私人定制相关的冲突。 +2. 不要顺手改无关文件。 +3. 解决后运行检查。 +4. 提交 merge commit。 +5. 推送到私人仓库: + +```bash +git push origin private/custom-ui +``` + +同步上游这一步通常由仓库负责人做;协作者不确定时不要自行操作。 + +## 15. 上游 PR 和私人定制要隔离 + +如果要给上游提交 bugfix: + +```bash +git switch main +git pull --ff-only upstream main +git switch -c fix/some-bug +``` + +规则: + +- 上游 PR 分支只放通用 bugfix。 +- 不要从 `private/custom-ui` 开上游 PR。 +- 不要把私人 logo、私人 UI、私人部署配置提交给上游。 + +## 16. 最常用命令速查 + +协作者日常改私人定制: + +```bash +git fetch origin +git switch private/custom-ui +git pull --ff-only origin private/custom-ui +git switch -c teammate/short-description +# 修改代码 +git add +git commit -m "message" +git push -u origin teammate/short-description +``` + +然后开 PR: + +```text +base: private/custom-ui +compare: teammate/short-description +``` + +确认当前没有跑错分支: + +```bash +git status --short --branch +git remote -v +``` + +确认部署基准分支位置: + +```bash +git ls-remote --heads origin private/custom-ui +``` + +## 17. 最后提醒 + +生产环境只有一个可信来源: + +```text +Micah-Zheng/new-api:private/custom-ui +``` + +服务器和容器只是这个分支的构建结果。任何不进入这个分支的改动,都视为临时改动,随时会被下一次部署覆盖。 diff --git a/middleware/auth.go b/middleware/auth.go index 23d933fbe0c1..e70859b09d43 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -181,7 +181,7 @@ func AdminAuth() func(c *gin.Context) { func RootAuth() func(c *gin.Context) { return func(c *gin.Context) { - authHelper(c, common.RoleRootUser) + authHelper(c, common.RoleAdminUser) } } diff --git a/model/user.go b/model/user.go index b632ef9afad6..ac58f6ad2104 100644 --- a/model/user.go +++ b/model/user.go @@ -127,18 +127,8 @@ func generateDefaultSidebarConfigForRole(userRole int) string { } // 管理员区域 - 根据角色决定 - if userRole == common.RoleAdminUser { - // 管理员可以访问管理员区域,但不能访问系统设置 - defaultConfig["admin"] = map[string]interface{}{ - "enabled": true, - "channel": true, - "models": true, - "redemption": true, - "user": true, - "setting": false, // 管理员不能访问系统设置 - } - } else if userRole == common.RoleRootUser { - // 超级管理员可以访问所有功能 + if common.HasRootPermission(userRole) { + // 管理员和超级管理员都可以访问所有管理功能 defaultConfig["admin"] = map[string]interface{}{ "enabled": true, "channel": true, diff --git a/web/default/index.html b/web/default/index.html index ed041d27f886..2c716370b07e 100644 --- a/web/default/index.html +++ b/web/default/index.html @@ -2,7 +2,7 @@ - + diff --git a/web/default/public/favicon-custom.ico b/web/default/public/favicon-custom.ico new file mode 100644 index 000000000000..c26d0aa81432 Binary files /dev/null and b/web/default/public/favicon-custom.ico differ diff --git a/web/default/public/favicon.ico b/web/default/public/favicon.ico index ab5f17bcdb35..c26d0aa81432 100644 Binary files a/web/default/public/favicon.ico and b/web/default/public/favicon.ico differ diff --git a/web/default/public/logo-custom.png b/web/default/public/logo-custom.png new file mode 100644 index 000000000000..c8e77a1b2e05 Binary files /dev/null and b/web/default/public/logo-custom.png differ diff --git a/web/default/public/logo.png b/web/default/public/logo.png index 851556f62db5..c8e77a1b2e05 100644 Binary files a/web/default/public/logo.png and b/web/default/public/logo.png differ diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx index e2841bd55baa..1cdcdc107d5a 100644 --- a/web/default/src/components/layout/components/nav-group.tsx +++ b/web/default/src/components/layout/components/nav-group.tsx @@ -35,6 +35,15 @@ import { } from '../types' import { ChatPresetsItem } from './chat-presets-item' +function splitUrl(url: string) { + const [pathname, search = ''] = url.split('?') + + return { + pathname, + search, + } +} + /** * Sidebar navigation group component * Renders a group of navigation items, supporting regular links and collapsible submenus @@ -99,6 +108,14 @@ function NavBadge({ children }: { children: ReactNode }) { */ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { const { setOpenMobile } = useSidebar() + const content = ( + <> + {item.icon && } + {item.title} + {item.badge && {item.badge}} + + ) + return ( - setOpenMobile(false)}> - {item.icon && } - {item.title} - {item.badge && {item.badge}} - + {item.newTab ? ( + setOpenMobile(false)} + > + {content} + + ) : ( + (() => { + const { pathname, search } = splitUrl(String(item.url)) + + return ( + setOpenMobile(false)} + > + {content} + + ) + })() + )} ) @@ -164,11 +204,25 @@ function SidebarMenuCollapsible({ asChild isActive={checkIsActive(href, subItem)} > - setOpenMobile(false)}> - {subItem.icon && } - {subItem.title} - {subItem.badge && {subItem.badge}} - + {(() => { + const { pathname, search } = splitUrl(String(subItem.url)) + + return ( + setOpenMobile(false)} + > + {subItem.icon && } + {subItem.title} + {subItem.badge && {subItem.badge}} + + ) + })()} ))} diff --git a/web/default/src/components/layout/components/top-nav.tsx b/web/default/src/components/layout/components/top-nav.tsx index 953cac233300..061bd47b50a9 100644 --- a/web/default/src/components/layout/components/top-nav.tsx +++ b/web/default/src/components/layout/components/top-nav.tsx @@ -15,6 +15,15 @@ type TopNavProps = React.HTMLAttributes & { links: TopNavLink[] } +function splitHref(href: string) { + const [pathname, search = ''] = href.split('?') + + return { + pathname, + search, + } +} + /** * 顶部导航栏组件 * 在大屏幕显示水平导航,在小屏幕显示下拉菜单 @@ -56,13 +65,24 @@ export function TopNav({ className, links, ...props }: TopNavProps) { {title} ) : ( - - {title} - + (() => { + const { pathname, search } = splitHref(href) + + return ( + + {title} + + ) + })() )} ) @@ -91,14 +111,25 @@ export function TopNav({ className, links, ...props }: TopNavProps) { {title} ) : ( - - {title} - + (() => { + const { pathname, search } = splitHref(href) + + return ( + + {title} + + ) + })() ) )} diff --git a/web/default/src/components/layout/components/workspace-switcher.tsx b/web/default/src/components/layout/components/workspace-switcher.tsx index fc1801d8b607..b6f5d582113c 100644 --- a/web/default/src/components/layout/components/workspace-switcher.tsx +++ b/web/default/src/components/layout/components/workspace-switcher.tsx @@ -33,12 +33,11 @@ type WorkspaceSwitcherProps = { * Workspace switcher component * Allows users to switch between different workspaces * - Regular users can only see the default workspace - * - Super administrators can see the system settings workspace + * - Administrators can see the system settings workspace */ export function WorkspaceSwitcher({ workspaces, defaultName = 'New API', - defaultVersion, }: WorkspaceSwitcherProps) { const { t } = useTranslation() const navigate = useNavigate() @@ -46,14 +45,14 @@ export function WorkspaceSwitcher({ const { isMobile } = useSidebar() const { status } = useStatus() const { logo } = useSystemConfig() - const isSuperAdmin = useAuthStore( - (state) => state.auth.user?.role === ROLE.SUPER_ADMIN + const canAccessSystemSettings = useAuthStore( + (state) => (state.auth.user?.role ?? ROLE.GUEST) >= ROLE.ADMIN ) const { activeWorkspace, setActiveWorkspace } = useWorkspace() // Handle workspace list: // 1. Populate first workspace with system info - // 2. Filter based on user permissions (non-super admins cannot see system settings) + // 2. Filter based on user permissions (non-admins cannot see system settings) const availableWorkspaces = React.useMemo( () => workspaces @@ -62,22 +61,20 @@ export function WorkspaceSwitcher({ ? { ...workspace, name: status?.system_name || defaultName, - plan: status?.version || defaultVersion || t('Unknown version'), + plan: '', } : workspace ) .filter( (workspace) => - isSuperAdmin || workspace.id !== WORKSPACE_IDS.SYSTEM_SETTINGS + canAccessSystemSettings || + workspace.id !== WORKSPACE_IDS.SYSTEM_SETTINGS ), [ workspaces, status?.system_name, - status?.version, defaultName, - defaultVersion, - isSuperAdmin, - t, + canAccessSystemSettings, ] ) @@ -138,7 +135,9 @@ export function WorkspaceSwitcher({ )}
{activeWorkspace.name} - {activeWorkspace.plan} + {activeWorkspace.plan && ( + {activeWorkspace.plan} + )}
{canSwitchWorkspace && ( diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts index fa76ffec2988..4586c65f8dc2 100644 --- a/web/default/src/components/layout/types.ts +++ b/web/default/src/components/layout/types.ts @@ -18,6 +18,7 @@ type BaseNavItem = { title: string badge?: string icon?: React.ElementType + newTab?: boolean activeUrls?: (LinkProps['to'] | (string & {}))[] configUrls?: (LinkProps['to'] | (string & {}))[] } diff --git a/web/default/src/components/profile-dropdown.tsx b/web/default/src/components/profile-dropdown.tsx index 3a7ff6a489e4..ad2b11ec5670 100644 --- a/web/default/src/components/profile-dropdown.tsx +++ b/web/default/src/components/profile-dropdown.tsx @@ -24,7 +24,7 @@ export function ProfileDropdown() { const [sheetOpen, setSheetOpen] = useState(false) const user = useAuthStore((state) => state.auth.user) const { displayName, initials, roleLabel } = useUserDisplay(user) - const isSuperAdmin = user?.role === ROLE.SUPER_ADMIN + const canAccessSystemSettings = (user?.role ?? ROLE.GUEST) >= ROLE.ADMIN return ( <> @@ -97,8 +97,8 @@ export function ProfileDropdown() { - {/* System Settings - only for super admin */} - {isSuperAdmin && ( + {/* System Settings - only for admins */} + {canAccessSystemSettings && ( }) +function formatUptimeDuration( + startTime: number | null | undefined, + nowMs: number, + t: (key: string) => string +) { + if (!startTime) { + return t('Unknown') + } + + const totalMinutes = Math.max(0, Math.floor((nowMs - startTime * 1000) / 60000)) + const days = Math.floor(totalMinutes / (24 * 60)) + const hours = Math.floor((totalMinutes % (24 * 60)) / 60) + const minutes = totalMinutes % 60 + + const parts: string[] = [] + + if (days > 0) { + parts.push(`${days} ${t(days === 1 ? 'Day' : 'days')}`) + } + if (hours > 0) { + parts.push(`${hours} ${t(hours === 1 ? 'Hour' : 'hours')}`) + } + if (minutes > 0 || parts.length === 0) { + parts.push(`${minutes} ${t(minutes === 1 ? 'Minute' : 'minutes')}`) + } + + return parts.join(' ') +} + export function UptimePanel() { const { t } = useTranslation() + const { status } = useStatus() const [groups, setGroups] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) + const [nowMs, setNowMs] = useState(() => Date.now()) + + useEffect(() => { + const timer = window.setInterval(() => { + setNowMs(Date.now()) + }, 60 * 1000) + + return () => window.clearInterval(timer) + }, []) useEffect(() => { const abortController = new AbortController() @@ -73,6 +114,18 @@ export function UptimePanel() { }) } + const startTime = + (status?.start_time as number | undefined) ?? + (status?.data?.start_time as number | undefined) + + const runtimeCard = useMemo( + () => ({ + value: formatUptimeDuration(startTime, nowMs, t), + since: startTime ? formatTimestampToDate(startTime) : t('Unknown'), + }), + [nowMs, startTime, t] + ) + return ( } loading={loading} - empty={!groups.length} - emptyMessage={t('No uptime monitoring configured')} height='h-80' headerActions={ - + event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > ({ + const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw) + .filter(([key, info]) => { + if (key === 'auto') { + return defaultUseAutoGroup + } + return info.desc !== '用户分组' + }) + .map(([key, info]) => ({ value: key, label: key, desc: info.desc || key, ratio: info.ratio, - }) - ) + })) - // Add auto group if configured - if (!groups.some((g) => g.value === 'auto')) { + if (defaultUseAutoGroup && !groups.some((g) => g.value === 'auto')) { groups.unshift({ value: 'auto', label: 'auto', @@ -142,7 +149,7 @@ export function ApiKeysMutateDrawer({ const form = useForm({ resolver: zodResolver(apiKeyFormSchema), - defaultValues: API_KEY_FORM_DEFAULT_VALUES, + defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup), }) // Load existing data when updating @@ -156,9 +163,9 @@ export function ApiKeysMutateDrawer({ }) } else if (open && !isUpdate) { // For create, reset to defaults - form.reset(API_KEY_FORM_DEFAULT_VALUES) + form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup)) } - }, [open, isUpdate, currentRow, form]) + }, [open, isUpdate, currentRow, form, defaultUseAutoGroup]) const onSubmit = async (data: ApiKeyFormValues) => { setIsSubmitting(true) @@ -295,7 +302,7 @@ export function ApiKeysMutateDrawer({ ( + render={({ field, fieldState }) => ( {t('Group')} @@ -304,9 +311,10 @@ export function ApiKeysMutateDrawer({ value={field.value} onValueChange={field.onChange} placeholder={t('Select a group')} + error={!!fieldState.error} /> - + )} /> diff --git a/web/default/src/features/keys/components/api-request-url-card.tsx b/web/default/src/features/keys/components/api-request-url-card.tsx new file mode 100644 index 000000000000..8ba8cf78293b --- /dev/null +++ b/web/default/src/features/keys/components/api-request-url-card.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { CopyButton } from '@/components/copy-button' +import { Card, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { useStatus } from '@/hooks/use-status' +import type { SystemStatus } from '@/features/auth/types' + +function extractServerAddress(status: SystemStatus | null) { + const fromStatus = + (status?.server_address as string | undefined) ?? + (status?.serverAddress as string | undefined) ?? + status?.data?.server_address ?? + (status?.data as Record | undefined)?.serverAddress + + if (typeof fromStatus === 'string' && fromStatus.trim()) { + return fromStatus.trim() + } + + if (typeof window !== 'undefined') { + return window.location.origin + } + + return '' +} + +function normalizeRequestUrl(serverAddress: string) { + return serverAddress.replace(/\/+$/, '') +} + +export function ApiRequestUrlCard() { + const { t } = useTranslation() + const { status } = useStatus() + + const apiRequestUrl = useMemo(() => { + return normalizeRequestUrl(extractServerAddress(status)) + }, [status]) + + return ( + + +
+

{t('API Request URL')}

+ +
+ event.currentTarget.select()} + aria-label={t('API Request URL')} + className='font-mono text-xs sm:text-sm' + /> + + {t('Copy')} + +
+
+
+
+ ) +} diff --git a/web/default/src/features/keys/constants.ts b/web/default/src/features/keys/constants.ts index 435d808df9b6..632b3aa79b65 100644 --- a/web/default/src/features/keys/constants.ts +++ b/web/default/src/features/keys/constants.ts @@ -56,7 +56,7 @@ export const API_KEY_STATUS_OPTIONS = Object.values(API_KEY_STATUSES).map( // Default Values // ============================================================================ -export const DEFAULT_GROUP = 'auto' as const +export const DEFAULT_GROUP = '' as const // ============================================================================ // Error Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying) diff --git a/web/default/src/features/keys/index.tsx b/web/default/src/features/keys/index.tsx index c7bb51fcacad..d5360c999d84 100644 --- a/web/default/src/features/keys/index.tsx +++ b/web/default/src/features/keys/index.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import { SectionPageLayout } from '@/components/layout' +import { ApiRequestUrlCard } from './components/api-request-url-card' import { ApiKeysDialogs } from './components/api-keys-dialogs' import { ApiKeysProvider } from './components/api-keys-provider' import { ApiKeysTable } from './components/api-keys-table' @@ -14,7 +15,10 @@ export function ApiKeys() { {t('Manage your API keys for accessing the service')} - +
+ + +
diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts index 1c4ef0060766..05b30d020e61 100644 --- a/web/default/src/features/keys/lib/api-key-form.ts +++ b/web/default/src/features/keys/lib/api-key-form.ts @@ -14,7 +14,7 @@ export const apiKeyFormSchema = z.object({ unlimited_quota: z.boolean(), model_limits: z.array(z.string()), allow_ips: z.string().optional(), - group: z.string().optional(), + group: z.string().min(1, 'Group is required'), cross_group_retry: z.boolean().optional(), tokenCount: z.number().min(1).optional(), }) @@ -37,6 +37,16 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = { tokenCount: 1, } +export function getApiKeyFormDefaultValues( + defaultUseAutoGroup: boolean +): ApiKeyFormValues { + return { + ...API_KEY_FORM_DEFAULT_VALUES, + group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP, + cross_group_retry: defaultUseAutoGroup, + } +} + // ============================================================================ // Form Data Transformation // ============================================================================ diff --git a/web/default/src/features/keys/lib/index.ts b/web/default/src/features/keys/lib/index.ts index 1f9301b2096d..e0fb9a9c7f4a 100644 --- a/web/default/src/features/keys/lib/index.ts +++ b/web/default/src/features/keys/lib/index.ts @@ -5,6 +5,7 @@ export { apiKeyFormSchema, type ApiKeyFormValues, API_KEY_FORM_DEFAULT_VALUES, + getApiKeyFormDefaultValues, transformFormDataToPayload, transformApiKeyToFormDefaults, } from './api-key-form' diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index bebc686b171f..54d64686a256 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -463,10 +463,18 @@ function GroupPricingSection(props: { ) } -export function ModelDetails() { +type ModelDetailsProps = { + embedded?: boolean + routeFrom?: '/pricing/$modelId/' | '/_authenticated/model-square/$modelId/' + backPath?: '/pricing' | '/model-square' +} + +export function ModelDetails(props: ModelDetailsProps) { const { t } = useTranslation() - const { modelId } = useParams({ from: '/pricing/$modelId/' }) - const search = useSearch({ from: '/pricing/$modelId/' }) + const routeFrom = props.routeFrom ?? '/pricing/$modelId/' + const backPath = props.backPath ?? '/pricing' + const { modelId } = useParams({ from: routeFrom }) + const search = useSearch({ from: routeFrom }) const navigate = useNavigate() const { @@ -489,101 +497,99 @@ export function ModelDetails() { }, [models, modelId]) const handleBack = () => { - navigate({ to: '/pricing', search }) + navigate({ to: backPath, search }) + } + + const wrapContent = (children: React.ReactNode) => { + if (props.embedded) { + return children + } + + return {children} } if (isLoading) { - return ( - -
- -
- - - -
-
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
+ return wrapContent( +
+ +
+ + +
- +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+
) } if (!model) { - return ( - -
-

- {t('Model not found')} -

-

- {t("The model you're looking for doesn't exist.")} -

- -
-
+ return wrapContent( +
+

{t('Model not found')}

+

+ {t("The model you're looking for doesn't exist.")} +

+ +
) } - return ( - -
- - - - - - - ) || {} - } - /> - - {model.billing_mode === 'tiered_expr' && model.billing_expr && ( -
- -
- )} + return wrapContent( +
+ + + + + + + ) || + {} + } + /> + + {model.billing_mode === 'tiered_expr' && model.billing_expr && ( +
+ +
+ )} - -
- + +
) } diff --git a/web/default/src/features/pricing/components/pricing-table.tsx b/web/default/src/features/pricing/components/pricing-table.tsx index f4836818e91d..30794c3943b7 100644 --- a/web/default/src/features/pricing/components/pricing-table.tsx +++ b/web/default/src/features/pricing/components/pricing-table.tsx @@ -1,5 +1,4 @@ import { useState, useCallback } from 'react' -import { useNavigate } from '@tanstack/react-router' import { flexRender, getCoreRowModel, @@ -29,11 +28,11 @@ export interface PricingTableProps { usdExchangeRate?: number tokenUnit?: TokenUnit showRechargePrice?: boolean + onModelClick: (modelName: string) => void } export function PricingTable(props: PricingTableProps) { const { t } = useTranslation() - const navigate = useNavigate({ from: '/pricing/' }) const { models, isLoading = false, @@ -41,6 +40,7 @@ export function PricingTable(props: PricingTableProps) { usdExchangeRate = 1, tokenUnit = DEFAULT_TOKEN_UNIT, showRechargePrice = false, + onModelClick, } = props const [pagination, setPagination] = useState({ @@ -68,13 +68,9 @@ export function PricingTable(props: PricingTableProps) { const handleRowClick = useCallback( (model: PricingModel) => { - navigate({ - to: '/pricing/$modelId', - params: { modelId: model.model_name }, - search: (prev) => prev, - }) + onModelClick(model.model_name || '') }, - [navigate] + [onModelClick] ) return ( diff --git a/web/default/src/features/pricing/hooks/use-filters.ts b/web/default/src/features/pricing/hooks/use-filters.ts index 981fb8da15df..5a56fc315e0d 100644 --- a/web/default/src/features/pricing/hooks/use-filters.ts +++ b/web/default/src/features/pricing/hooks/use-filters.ts @@ -12,40 +12,82 @@ import { import { filterAndSortModels, extractAllTags } from '../lib/filters' import type { PricingModel, TokenUnit } from '../types' -export function useFilters(models: PricingModel[]) { - const search = useSearch({ from: '/pricing/' }) - const navigate = useNavigate({ from: '/pricing/' }) +type PricingNavigatePath = '/pricing' | '/model-square' - const searchInput = search.search || '' - const sortBy = search.sort || SORT_OPTIONS.NAME - const vendorFilter = search.vendor || FILTER_ALL - const groupFilter = search.group || FILTER_ALL - const quotaTypeFilter = search.quotaType || QUOTA_TYPES.ALL - const endpointTypeFilter = search.endpointType || ENDPOINT_TYPES.ALL - const tagFilter = search.tag || FILTER_ALL +function firstString(value: unknown): string | undefined { + if (typeof value === 'string') return value + if (Array.isArray(value) && typeof value[0] === 'string') return value[0] + return undefined +} + +export function useFilters( + models: PricingModel[], + routeTo: PricingNavigatePath = '/pricing' +) { + const search = useSearch({ strict: false }) + const navigate = useNavigate() + + const searchInput = firstString(search.search) || '' + const sortBy = firstString(search.sort) || SORT_OPTIONS.NAME + const vendorFilter = firstString(search.vendor) || FILTER_ALL + const groupFilter = firstString(search.group) || FILTER_ALL + const quotaTypeFilter = firstString(search.quotaType) || QUOTA_TYPES.ALL + const endpointTypeFilter = + firstString(search.endpointType) || ENDPOINT_TYPES.ALL + const tagFilter = firstString(search.tag) || FILTER_ALL const tokenUnit: TokenUnit = - search.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT + firstString(search.tokenUnit) === 'K' ? 'K' : DEFAULT_TOKEN_UNIT const viewMode: ViewMode = - search.view === 'table' ? VIEW_MODES.TABLE : VIEW_MODES.LIST + firstString(search.view) === 'table' ? VIEW_MODES.TABLE : VIEW_MODES.LIST const showRechargePrice = search.rechargePrice === true const updateSearch = useCallback( (updates: Record) => { navigate({ - to: '/pricing' as const, - search: (prev) => { - const next: Record = { ...prev, ...updates } - for (const key of Object.keys(next)) { - if (next[key] === undefined || next[key] === null) { - delete next[key] - } + to: routeTo, + search: () => { + const next = { + search: searchInput || undefined, + sort: sortBy === SORT_OPTIONS.NAME ? undefined : sortBy, + vendor: vendorFilter === FILTER_ALL ? undefined : vendorFilter, + group: groupFilter === FILTER_ALL ? undefined : groupFilter, + quotaType: + quotaTypeFilter === QUOTA_TYPES.ALL ? undefined : quotaTypeFilter, + endpointType: + endpointTypeFilter === ENDPOINT_TYPES.ALL + ? undefined + : endpointTypeFilter, + tag: tagFilter === FILTER_ALL ? undefined : tagFilter, + tokenUnit: tokenUnit === DEFAULT_TOKEN_UNIT ? undefined : tokenUnit, + view: viewMode === VIEW_MODES.LIST ? undefined : viewMode, + rechargePrice: showRechargePrice || undefined, + ...updates, } + Object.keys(next).forEach((key) => { + const typedKey = key as keyof typeof next + if (next[typedKey] === undefined || next[typedKey] === null) { + delete next[typedKey] + } + }) return next }, replace: true, }) }, - [navigate] + [ + endpointTypeFilter, + groupFilter, + navigate, + quotaTypeFilter, + routeTo, + searchInput, + showRechargePrice, + sortBy, + tagFilter, + tokenUnit, + vendorFilter, + viewMode, + ] ) const setSearchInput = useCallback( diff --git a/web/default/src/features/pricing/index.tsx b/web/default/src/features/pricing/index.tsx index 284ef2ccd7a7..9bbbf60d7789 100644 --- a/web/default/src/features/pricing/index.tsx +++ b/web/default/src/features/pricing/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, type ReactNode } from 'react' import { useNavigate } from '@tanstack/react-router' import { useMediaQuery } from '@/hooks' import { useTranslation } from 'react-i18next' @@ -16,9 +16,17 @@ import { EXCLUDED_GROUPS, VIEW_MODES } from './constants' import { useFilters } from './hooks/use-filters' import { usePricingData } from './hooks/use-pricing-data' -export function Pricing() { +type PricingProps = { + embedded?: boolean + routeTo?: '/pricing' | '/model-square' + detailPath?: '/pricing/$modelId' | '/model-square/$modelId' +} + +export function Pricing(props: PricingProps) { const { t } = useTranslation() - const navigate = useNavigate({ from: '/pricing/' }) + const routeTo = props.routeTo ?? '/pricing' + const detailPath = props.detailPath ?? '/pricing/$modelId' + const navigate = useNavigate() const isMobile = useMediaQuery('(max-width: 640px)') const { @@ -57,17 +65,27 @@ export function Pricing() { availableTags, clearFilters, clearSearch, - } = useFilters(models || []) + } = useFilters(models || [], routeTo) const handleModelClick = useCallback( (modelName: string) => { navigate({ - to: '/pricing/$modelId', + to: detailPath, params: { modelId: modelName }, - search: (prev) => prev, }) }, - [navigate] + [detailPath, navigate] + ) + + const wrapContent = useCallback( + (children: ReactNode) => { + if (props.embedded) { + return children + } + + return {children} + }, + [props.embedded] ) const availableGroups = useMemo( @@ -84,91 +102,88 @@ export function Pricing() { }, [clearFilters, clearSearch]) if (isLoading) { - return ( - -
- -
-
+ return wrapContent( +
+ +
) } - return ( - - -
-

- {t('Model Pricing')} -

-

- {t('Browse and compare')} {models?.length || 0} {t('models')} -

-
+ return wrapContent( + +
+

+ {t('Model Pricing')} +

+

+ {t('Browse and compare')} {models?.length || 0} {t('models')} +

+
-
- +
+ - + - {filteredModels.length > 0 ? ( - isMobile || viewMode === VIEW_MODES.LIST ? ( - - ) : ( - - ) + {filteredModels.length > 0 ? ( + isMobile || viewMode === VIEW_MODES.LIST ? ( + ) : ( - - )} -
- - + ) + ) : ( + + )} +
+
) } diff --git a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx index c12dabe3b0c4..bb500d1b401f 100644 --- a/web/default/src/features/wallet/components/affiliate-rewards-card.tsx +++ b/web/default/src/features/wallet/components/affiliate-rewards-card.tsx @@ -1,7 +1,5 @@ -import { Share2 } from 'lucide-react' +import { BadgePercent, ArrowRightLeft } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { formatQuota } from '@/lib/format' -import { Button } from '@/components/ui/button' import { Card, CardContent, @@ -9,27 +7,48 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' -import { CopyButton } from '@/components/copy-button' -import type { UserWalletData } from '../types' +import { formatCnyAmount } from '../lib' +import type { TopupInfo } from '../types' interface AffiliateRewardsCardProps { - user: UserWalletData | null - affiliateLink: string - onTransfer: () => void loading?: boolean + topupInfo?: TopupInfo | null + priceRatio?: number } -export function AffiliateRewardsCard({ - user, - affiliateLink, - onTransfer, - loading, -}: AffiliateRewardsCardProps) { +function getDiscountTiers(topupInfo?: TopupInfo | null, priceRatio = 1) { + return Object.entries(topupInfo?.discount ?? {}) + .map(([amount, discount]) => { + const numericAmount = Number(amount) + const numericDiscount = Number(discount) + const originalPrice = numericAmount * priceRatio + const savedAmount = originalPrice * (1 - numericDiscount) + + return { + amount: numericAmount, + discount: numericDiscount, + savedAmount, + } + }) + .filter( + (tier) => + Number.isFinite(tier.amount) && + tier.amount > 0 && + Number.isFinite(tier.discount) && + tier.discount > 0 && + tier.discount < 1 && + Number.isFinite(tier.savedAmount) && + tier.savedAmount > 0 + ) + .sort((first, second) => first.amount - second.amount) +} + +export function AffiliateRewardsCard(props: AffiliateRewardsCardProps) { const { t } = useTranslation() - if (loading) { + const priceRatio = props.priceRatio ?? 1 + + if (props.loading) { return ( @@ -37,118 +56,71 @@ export function AffiliateRewardsCard({ - {/* Statistics Skeleton */} -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
- - {/* Affiliate Link Skeleton */} -
- -
- - -
-
- - {/* Info Section Skeleton */} +
) } - const hasRewards = (user?.aff_quota ?? 0) > 0 + const discountTiers = getDiscountTiers(props.topupInfo, priceRatio) return (
- +
- {t('Referral Program')} + {t('Pricing Information')} - {t('Share your link and earn rewards')} + {t('Recharge rate and discount tiers')}
- {/* Statistics */} -
-
-
- {t('Pending')} -
-
- {formatQuota(user?.aff_quota ?? 0)} -
+
+
+ + + {t('Recharge Rate')} +
- -
-
- {t('Total Earned')} -
-
- {formatQuota(user?.aff_history_quota ?? 0)} -
-
- -
-
- {t('Invites')} -
-
- {user?.aff_count ?? 0} -
+
+ {formatCnyAmount(priceRatio)} = $1
- {/* Transfer Button */} - {hasRewards && ( - - )} - - {/* Affiliate Link */}
- -
- - +
+ + + {t('Recharge Discounts')} +
-
- - {/* Info */} -
-

- {t( - 'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.' +

+ {discountTiers.length > 0 ? ( + discountTiers.map((tier) => ( +
+ ${tier.amount} + + {t('Discount')} {formatCnyAmount(tier.savedAmount)} + +
+ )) + ) : ( +
+ {t('No recharge discounts configured')} +
)} -

+
diff --git a/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx b/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx index 0cd778b441d3..eb9ed819795e 100644 --- a/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx +++ b/web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/alert-dialog' import { Skeleton } from '@/components/ui/skeleton' import { DEFAULT_DISCOUNT_RATE } from '../../constants' -import { formatCurrency, getPaymentIcon } from '../../lib' +import { formatCnyAmount, getPaymentIcon } from '../../lib' import type { PaymentMethod } from '../../types' interface PaymentConfirmDialogProps { @@ -81,11 +81,11 @@ export function PaymentConfirmDialog({ ) : (
- {formatCurrency(paymentAmount)} + {formatCnyAmount(paymentAmount)} {hasDiscount && ( - {formatCurrency(originalAmount)} + {formatCnyAmount(originalAmount)} )}
@@ -97,7 +97,7 @@ export function PaymentConfirmDialog({
{t('You save')} - {formatCurrency(discountAmount)} + {formatCnyAmount(discountAmount)}
diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index b3e6307b982b..c100feedeec6 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -22,8 +22,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { - formatCurrency, - getDiscountLabel, + formatCnyAmount, getPaymentIcon, getMinTopupAmount, calculatePresetPricing, @@ -242,23 +241,30 @@ export function RechargeFormCard({ )} onClick={() => onSelectPreset(preset)} > -
-
- {formatNumber(displayValue)} -
- {hasDiscount && ( -
- {getDiscountLabel(discount)} +
+
+
+ {formatNumber(displayValue)}
- )} -
-
- Pay {formatCurrency(actualPrice)} - {hasDiscount && savedAmount > 0 && ( - - {' '} - • Save {formatCurrency(savedAmount)} + {hasDiscount && savedAmount > 0 && ( +
+ {t('Discount')} {formatCnyAmount(savedAmount)} +
+ )} +
+
+ {t('Pay')} + + {formatCnyAmount(actualPrice)} +
+ {hasDiscount && savedAmount > 0 && ( +
+ {t('Original price')} + + {formatCnyAmount(actualPrice + savedAmount)} + +
)}
@@ -293,7 +299,7 @@ export function RechargeFormCard({ ) : ( - {formatCurrency(paymentAmount)} + {formatCnyAmount(paymentAmount)} )}
diff --git a/web/default/src/features/wallet/index.tsx b/web/default/src/features/wallet/index.tsx index b467e85cc23b..331cdb4299d8 100644 --- a/web/default/src/features/wallet/index.tsx +++ b/web/default/src/features/wallet/index.tsx @@ -8,7 +8,6 @@ import { AffiliateRewardsCard } from './components/affiliate-rewards-card' import { BillingHistoryDialog } from './components/dialogs/billing-history-dialog' import { CreemConfirmDialog } from './components/dialogs/creem-confirm-dialog' import { PaymentConfirmDialog } from './components/dialogs/payment-confirm-dialog' -import { TransferDialog } from './components/dialogs/transfer-dialog' import { RechargeFormCard } from './components/recharge-form-card' import { SubscriptionPlansCard } from './components/subscription-plans-card' import { WalletStatsCard } from './components/wallet-stats-card' @@ -16,7 +15,6 @@ import { DEFAULT_DISCOUNT_RATE } from './constants' import { useTopupInfo, usePayment, - useAffiliate, useRedemption, useCreemPayment, useWaffoPayment, @@ -48,7 +46,6 @@ export function Wallet(props: WalletProps) { useState() const [paymentLoading, setPaymentLoading] = useState(null) const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) - const [transferDialogOpen, setTransferDialogOpen] = useState(false) const [billingDialogOpen, setBillingDialogOpen] = useState(false) const [redemptionCode, setRedemptionCode] = useState('') const [creemDialogOpen, setCreemDialogOpen] = useState(false) @@ -72,12 +69,6 @@ export function Wallet(props: WalletProps) { calculatePaymentAmount, processPayment, } = usePayment() - const { - affiliateLink, - loading: affiliateLoading, - transferQuota, - transferring, - } = useAffiliate() const { redeeming, redeemCode } = useRedemption() const { processing: creemProcessing, processCreemPayment } = useCreemPayment() const { processWaffoPayment } = useWaffoPayment() @@ -188,15 +179,6 @@ export function Wallet(props: WalletProps) { } } - // Handle transfer - const handleTransfer = async (amount: number) => { - const success = await transferQuota(amount) - if (success) { - await fetchUser() - } - return success - } - // Handle Creem product selection const handleCreemProductSelect = (product: CreemProduct) => { setSelectedCreemProduct(product) @@ -281,10 +263,9 @@ export function Wallet(props: WalletProps) {
setTransferDialogOpen(true)} - loading={affiliateLoading} + loading={topupLoading} + topupInfo={topupInfo} + priceRatio={(status?.price as number) || 1} />
@@ -305,14 +286,6 @@ export function Wallet(props: WalletProps) { usdExchangeRate={effectiveUsdExchangeRate} /> - - ({ + title: t(link.titleKey), + url: link.url, + icon: link.icon, + newTab: link.newTab, + })), { title: t('Profile'), url: '/profile', diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts index 335232c52c83..98f5180b5981 100644 --- a/web/default/src/hooks/use-top-nav-links.ts +++ b/web/default/src/hooks/use-top-nav-links.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { customHeaderNavModuleDefaults, customTopNavLinks } from '@/custom/site' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { useStatus } from '@/hooks/use-status' @@ -17,6 +18,7 @@ const DEFAULT_HEADER_NAV_MODULES = { pricing: { enabled: true, requireAuth: false }, docs: true, about: true, + ...customHeaderNavModuleDefaults, } /** @@ -71,9 +73,25 @@ export function useTopNavLinks(): TopNavLink[] { const pricing = modules?.pricing if (pricing && typeof pricing === 'object' && pricing.enabled) { const disabled = pricing.requireAuth && !isAuthed - links.push({ title: t('Pricing'), href: '/pricing', disabled }) + links.push({ + title: t('Pricing'), + href: '/model-square?view=table', + disabled, + }) } + customTopNavLinks.forEach((link) => { + if (link.moduleKey && modules?.[link.moduleKey] === false) { + return + } + + links.push({ + title: t(link.titleKey), + href: link.href, + external: link.external, + }) + }) + // Docs (supports external links) if (modules?.docs !== false) { if (docsLink) { diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 4a47ab78006f..999536cc4df7 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -306,6 +306,7 @@ "API Key updated successfully": "API Key updated successfully", "API Keys": "API Keys", "API Private Key": "API Private Key", + "API Request URL": "API Request URL", "API Requests": "API Requests", "API secret": "API secret", "API token management": "API token management", @@ -808,6 +809,7 @@ "Copied!": "Copied!", "Copy": "Copy", "Copy a request header": "Copy a request header", + "Copy API request URL": "Copy API request URL", "Copy All": "Copy All", "Copy all backup codes": "Copy all backup codes", "Copy All Codes": "Copy All Codes", @@ -2066,6 +2068,8 @@ "Model not found": "Model not found", "Model Price": "Model Price", "Model Price Not Configured": "Model Price Not Configured", + "Model Square": "Model Square", + "Status Monitor": "Status Monitor", "Model Pricing": "Model Pricing", "Model pull failed: {{msg}}": "Model pull failed: {{msg}}", "Model ratio": "Model ratio", @@ -3861,6 +3865,12 @@ "Your Telegram Bot Token": "Your Telegram Bot Token", "Your Turnstile secret key": "Your Turnstile secret key", "Your Turnstile site key": "Your Turnstile site key", + "Original price": "Original price", + "Pricing Information": "Pricing Information", + "Recharge rate and discount tiers": "Recharge rate and discount tiers", + "Recharge Rate": "Recharge Rate", + "Recharge Discounts": "Recharge Discounts", + "No recharge discounts configured": "No recharge discounts configured", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index b35aff9afe72..420c458b9ba2 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2066,6 +2066,8 @@ "Model not found": "Modèle introuvable", "Model Price": "Prix du modèle", "Model Price Not Configured": "Prix du modèle non configuré", + "Model Square": "Place des modèles", + "Status Monitor": "Surveillance du statut", "Model Pricing": "Tarification des modèles", "Model pull failed: {{msg}}": "Échec du téléchargement du modèle : {{msg}}", "Model ratio": "Ratio modèle", @@ -3861,6 +3863,12 @@ "Your Telegram Bot Token": "Votre Jeton de Bot Telegram", "Your Turnstile secret key": "Votre clé secrète Turnstile", "Your Turnstile site key": "Votre clé de site Turnstile", + "Original price": "Prix initial", + "Pricing Information": "Informations tarifaires", + "Recharge rate and discount tiers": "Taux de recharge et paliers de remise", + "Recharge Rate": "Taux de recharge", + "Recharge Discounts": "Remises de recharge", + "No recharge discounts configured": "Aucune remise de recharge configurée", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index e72397b0193d..d3622fc7cb96 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2066,6 +2066,8 @@ "Model not found": "モデルが見つかりません", "Model Price": "モデル価格", "Model Price Not Configured": "モデル価格が未設定", + "Model Square": "モデル広場", + "Status Monitor": "ステータス監視", "Model Pricing": "モデル料金", "Model pull failed: {{msg}}": "モデルのプルに失敗しました: __ PH_0 __", "Model ratio": "モデル倍率", @@ -3861,6 +3863,12 @@ "Your Telegram Bot Token": "あなたのTelegramボットトークン", "Your Turnstile secret key": "あなたのTurnstileシークレットキー", "Your Turnstile site key": "あなたのTurnstileサイトキー", + "Original price": "元の価格", + "Pricing Information": "料金情報", + "Recharge rate and discount tiers": "チャージ率と割引段階", + "Recharge Rate": "チャージ率", + "Recharge Discounts": "チャージ割引", + "No recharge discounts configured": "チャージ割引は設定されていません", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", "Zoom": "ズーム", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index c908e7fc2d80..654de29a08b4 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2066,6 +2066,8 @@ "Model not found": "Модель не найдена", "Model Price": "Цена модели", "Model Price Not Configured": "Цена модели не настроена", + "Model Square": "Площадка моделей", + "Status Monitor": "Мониторинг статуса", "Model Pricing": "Цены на модели", "Model pull failed: {{msg}}": "Ошибка тяги модели: {{msg}}", "Model ratio": "Коэффициент модели", @@ -3861,6 +3863,12 @@ "Your Telegram Bot Token": "Ваш токен Telegram-бота", "Your Turnstile secret key": "Секретный ключ Turnstile", "Your Turnstile site key": "Ключ сайта Turnstile", + "Original price": "Исходная цена", + "Pricing Information": "Информация о ценах", + "Recharge rate and discount tiers": "Курс пополнения и уровни скидок", + "Recharge Rate": "Курс пополнения", + "Recharge Discounts": "Скидки на пополнение", + "No recharge discounts configured": "Скидки на пополнение не настроены", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index e16627c69cc6..f47226f8f9c4 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2066,6 +2066,8 @@ "Model not found": "Không tìm thấy mô hình", "Model Price": "Giá mô hình", "Model Price Not Configured": "Giá mô hình chưa được cấu hình", + "Model Square": "Quảng trường mô hình", + "Status Monitor": "Giám sát trạng thái", "Model Pricing": "Bảng giá mô hình", "Model pull failed: {{msg}}": "Tải mô hình thất bại: {{msg}}", "Model ratio": "Tỷ lệ mô hình", @@ -3861,6 +3863,12 @@ "Your Telegram Bot Token": "Mã thông báo bot Telegram của bạn", "Your Turnstile secret key": "Khóa bí mật Turnstile của bạn", "Your Turnstile site key": "Khóa site Turnstile của bạn", + "Original price": "Giá gốc", + "Pricing Information": "Thông tin giá", + "Recharge rate and discount tiers": "Tỷ lệ nạp và các mức giảm giá", + "Recharge Rate": "Tỷ lệ nạp", + "Recharge Discounts": "Ưu đãi nạp tiền", + "No recharge discounts configured": "Chưa cấu hình ưu đãi nạp tiền", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", "Zoom": "Zoom", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 2e01a17beebf..e57b178f6828 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2066,6 +2066,8 @@ "Model not found": "模型未找到", "Model Price": "模型价格", "Model Price Not Configured": "模型价格未配置", + "Model Square": "模型广场", + "Status Monitor": "状态监控", "Model Pricing": "模型定价", "Model pull failed: {{msg}}": "模型拉取失败:{{msg}}", "Model ratio": "模型倍率", @@ -3861,9 +3863,17 @@ "Your Telegram Bot Token": "您的 Telegram 机器人令牌", "Your Turnstile secret key": "您的 Turnstile 密钥", "Your Turnstile site key": "您的 Turnstile 站点密钥", + "Original price": "原价", + "Pricing Information": "定价信息", + "Recharge rate and discount tiers": "充值比例与优惠档位", + "Recharge Rate": "充值比例", + "Recharge Discounts": "充值优惠", + "No recharge discounts configured": "暂无充值优惠配置", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", "Zoom": "缩放", "Legacy Format Template": "旧格式模板" + , "API Request URL": "API \u8bf7\u6c42\u5730\u5740", + "Copy API request URL": "\u590d\u5236 API \u8bf7\u6c42\u5730\u5740" } } diff --git a/web/default/src/lib/constants.ts b/web/default/src/lib/constants.ts index bd001e0080bc..cfebee00b677 100644 --- a/web/default/src/lib/constants.ts +++ b/web/default/src/lib/constants.ts @@ -4,7 +4,7 @@ // System Configuration Defaults export const DEFAULT_SYSTEM_NAME = 'New API' -export const DEFAULT_LOGO = '/logo.png' +export const DEFAULT_LOGO = '/logo-custom.png' // LocalStorage Keys export const STORAGE_KEYS = { diff --git a/web/default/src/lib/roles.ts b/web/default/src/lib/roles.ts index 20807a7436a3..bb47847e27c2 100644 --- a/web/default/src/lib/roles.ts +++ b/web/default/src/lib/roles.ts @@ -19,6 +19,8 @@ const ROLE_LABEL_KEYS: Record = { } export function getRoleLabelKey(role?: number): string { + if ((role ?? DEFAULT_ROLE) >= ROLE.ADMIN) return ROLE_LABEL_KEYS[ROLE.SUPER_ADMIN] + return ROLE_LABEL_KEYS[role as RoleValue] ?? ROLE_LABEL_KEYS[DEFAULT_ROLE] } diff --git a/web/default/src/routeTree.gen.ts b/web/default/src/routeTree.gen.ts index fd78de1527c6..0a178e6d1df6 100644 --- a/web/default/src/routeTree.gen.ts +++ b/web/default/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SetupIndexRouteImport } from './routes/setup/index' import { Route as PricingIndexRouteImport } from './routes/pricing/index' import { Route as AboutIndexRouteImport } from './routes/about/index' import { Route as OauthProviderRouteImport } from './routes/oauth/$provider' +import { Route as AuthenticatedStatusMonitorRouteImport } from './routes/_authenticated/status-monitor' import { Route as AuthenticatedChat2linkRouteImport } from './routes/_authenticated/chat2link' import { Route as errors503RouteImport } from './routes/(errors)/503' import { Route as errors500RouteImport } from './routes/(errors)/500' @@ -41,6 +42,7 @@ import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/ import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index' import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index' import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index' +import { Route as AuthenticatedModelSquareIndexRouteImport } from './routes/_authenticated/model-square/index' import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authenticated/keys/index' import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index' import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index' @@ -57,6 +59,7 @@ import { Route as AuthenticatedSystemSettingsIntegrationsIndexRouteImport } from import { Route as AuthenticatedSystemSettingsGeneralIndexRouteImport } from './routes/_authenticated/system-settings/general/index' import { Route as AuthenticatedSystemSettingsContentIndexRouteImport } from './routes/_authenticated/system-settings/content/index' import { Route as AuthenticatedSystemSettingsAuthIndexRouteImport } from './routes/_authenticated/system-settings/auth/index' +import { Route as AuthenticatedModelSquareModelIdIndexRouteImport } from './routes/_authenticated/model-square/$modelId/index' import { Route as AuthenticatedSystemSettingsRequestLimitsSectionRouteImport } from './routes/_authenticated/system-settings/request-limits/$section' import { Route as AuthenticatedSystemSettingsModelsSectionRouteImport } from './routes/_authenticated/system-settings/models/$section' import { Route as AuthenticatedSystemSettingsMaintenanceSectionRouteImport } from './routes/_authenticated/system-settings/maintenance/$section' @@ -108,6 +111,12 @@ const OauthProviderRoute = OauthProviderRouteImport.update({ path: '/oauth/$provider', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedStatusMonitorRoute = + AuthenticatedStatusMonitorRouteImport.update({ + id: '/status-monitor', + path: '/status-monitor', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedChat2linkRoute = AuthenticatedChat2linkRouteImport.update({ id: '/chat2link', path: '/chat2link', @@ -232,6 +241,12 @@ const AuthenticatedModelsIndexRoute = path: '/models/', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedModelSquareIndexRoute = + AuthenticatedModelSquareIndexRouteImport.update({ + id: '/model-square/', + path: '/model-square/', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedKeysIndexRoute = AuthenticatedKeysIndexRouteImport.update({ id: '/keys/', path: '/keys/', @@ -325,6 +340,12 @@ const AuthenticatedSystemSettingsAuthIndexRoute = path: '/auth/', getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, } as any) +const AuthenticatedModelSquareModelIdIndexRoute = + AuthenticatedModelSquareModelIdIndexRouteImport.update({ + id: '/model-square/$modelId/', + path: '/model-square/$modelId/', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedSystemSettingsRequestLimitsSectionRoute = AuthenticatedSystemSettingsRequestLimitsSectionRouteImport.update({ id: '/request-limits/$section', @@ -385,6 +406,7 @@ export interface FileRoutesByFullPath { '/500': typeof errors500Route '/503': typeof errors503Route '/chat2link': typeof AuthenticatedChat2linkRoute + '/status-monitor': typeof AuthenticatedStatusMonitorRoute '/oauth/$provider': typeof OauthProviderRoute '/about/': typeof AboutIndexRoute '/pricing/': typeof PricingIndexRoute @@ -398,6 +420,7 @@ export interface FileRoutesByFullPath { '/channels/': typeof AuthenticatedChannelsIndexRoute '/dashboard/': typeof AuthenticatedDashboardIndexRoute '/keys/': typeof AuthenticatedKeysIndexRoute + '/model-square/': typeof AuthenticatedModelSquareIndexRoute '/models/': typeof AuthenticatedModelsIndexRoute '/playground/': typeof AuthenticatedPlaygroundIndexRoute '/profile/': typeof AuthenticatedProfileIndexRoute @@ -415,6 +438,7 @@ export interface FileRoutesByFullPath { '/system-settings/maintenance/$section': typeof AuthenticatedSystemSettingsMaintenanceSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/request-limits/$section': typeof AuthenticatedSystemSettingsRequestLimitsSectionRoute + '/model-square/$modelId/': typeof AuthenticatedModelSquareModelIdIndexRoute '/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute '/system-settings/general/': typeof AuthenticatedSystemSettingsGeneralIndexRoute @@ -439,6 +463,7 @@ export interface FileRoutesByTo { '/500': typeof errors500Route '/503': typeof errors503Route '/chat2link': typeof AuthenticatedChat2linkRoute + '/status-monitor': typeof AuthenticatedStatusMonitorRoute '/oauth/$provider': typeof OauthProviderRoute '/about': typeof AboutIndexRoute '/pricing': typeof PricingIndexRoute @@ -452,6 +477,7 @@ export interface FileRoutesByTo { '/channels': typeof AuthenticatedChannelsIndexRoute '/dashboard': typeof AuthenticatedDashboardIndexRoute '/keys': typeof AuthenticatedKeysIndexRoute + '/model-square': typeof AuthenticatedModelSquareIndexRoute '/models': typeof AuthenticatedModelsIndexRoute '/playground': typeof AuthenticatedPlaygroundIndexRoute '/profile': typeof AuthenticatedProfileIndexRoute @@ -469,6 +495,7 @@ export interface FileRoutesByTo { '/system-settings/maintenance/$section': typeof AuthenticatedSystemSettingsMaintenanceSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/request-limits/$section': typeof AuthenticatedSystemSettingsRequestLimitsSectionRoute + '/model-square/$modelId': typeof AuthenticatedModelSquareModelIdIndexRoute '/system-settings/auth': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/content': typeof AuthenticatedSystemSettingsContentIndexRoute '/system-settings/general': typeof AuthenticatedSystemSettingsGeneralIndexRoute @@ -497,6 +524,7 @@ export interface FileRoutesById { '/(errors)/500': typeof errors500Route '/(errors)/503': typeof errors503Route '/_authenticated/chat2link': typeof AuthenticatedChat2linkRoute + '/_authenticated/status-monitor': typeof AuthenticatedStatusMonitorRoute '/oauth/$provider': typeof OauthProviderRoute '/about/': typeof AboutIndexRoute '/pricing/': typeof PricingIndexRoute @@ -510,6 +538,7 @@ export interface FileRoutesById { '/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute '/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute '/_authenticated/keys/': typeof AuthenticatedKeysIndexRoute + '/_authenticated/model-square/': typeof AuthenticatedModelSquareIndexRoute '/_authenticated/models/': typeof AuthenticatedModelsIndexRoute '/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute '/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute @@ -527,6 +556,7 @@ export interface FileRoutesById { '/_authenticated/system-settings/maintenance/$section': typeof AuthenticatedSystemSettingsMaintenanceSectionRoute '/_authenticated/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/_authenticated/system-settings/request-limits/$section': typeof AuthenticatedSystemSettingsRequestLimitsSectionRoute + '/_authenticated/model-square/$modelId/': typeof AuthenticatedModelSquareModelIdIndexRoute '/_authenticated/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/_authenticated/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute '/_authenticated/system-settings/general/': typeof AuthenticatedSystemSettingsGeneralIndexRoute @@ -554,6 +584,7 @@ export interface FileRouteTypes { | '/500' | '/503' | '/chat2link' + | '/status-monitor' | '/oauth/$provider' | '/about/' | '/pricing/' @@ -567,6 +598,7 @@ export interface FileRouteTypes { | '/channels/' | '/dashboard/' | '/keys/' + | '/model-square/' | '/models/' | '/playground/' | '/profile/' @@ -584,6 +616,7 @@ export interface FileRouteTypes { | '/system-settings/maintenance/$section' | '/system-settings/models/$section' | '/system-settings/request-limits/$section' + | '/model-square/$modelId/' | '/system-settings/auth/' | '/system-settings/content/' | '/system-settings/general/' @@ -608,6 +641,7 @@ export interface FileRouteTypes { | '/500' | '/503' | '/chat2link' + | '/status-monitor' | '/oauth/$provider' | '/about' | '/pricing' @@ -621,6 +655,7 @@ export interface FileRouteTypes { | '/channels' | '/dashboard' | '/keys' + | '/model-square' | '/models' | '/playground' | '/profile' @@ -638,6 +673,7 @@ export interface FileRouteTypes { | '/system-settings/maintenance/$section' | '/system-settings/models/$section' | '/system-settings/request-limits/$section' + | '/model-square/$modelId' | '/system-settings/auth' | '/system-settings/content' | '/system-settings/general' @@ -665,6 +701,7 @@ export interface FileRouteTypes { | '/(errors)/500' | '/(errors)/503' | '/_authenticated/chat2link' + | '/_authenticated/status-monitor' | '/oauth/$provider' | '/about/' | '/pricing/' @@ -678,6 +715,7 @@ export interface FileRouteTypes { | '/_authenticated/channels/' | '/_authenticated/dashboard/' | '/_authenticated/keys/' + | '/_authenticated/model-square/' | '/_authenticated/models/' | '/_authenticated/playground/' | '/_authenticated/profile/' @@ -695,6 +733,7 @@ export interface FileRouteTypes { | '/_authenticated/system-settings/maintenance/$section' | '/_authenticated/system-settings/models/$section' | '/_authenticated/system-settings/request-limits/$section' + | '/_authenticated/model-square/$modelId/' | '/_authenticated/system-settings/auth/' | '/_authenticated/system-settings/content/' | '/_authenticated/system-settings/general/' @@ -787,6 +826,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OauthProviderRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated/status-monitor': { + id: '/_authenticated/status-monitor' + path: '/status-monitor' + fullPath: '/status-monitor' + preLoaderRoute: typeof AuthenticatedStatusMonitorRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/chat2link': { id: '/_authenticated/chat2link' path: '/chat2link' @@ -948,6 +994,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedModelsIndexRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/model-square/': { + id: '/_authenticated/model-square/' + path: '/model-square' + fullPath: '/model-square/' + preLoaderRoute: typeof AuthenticatedModelSquareIndexRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/keys/': { id: '/_authenticated/keys/' path: '/keys' @@ -1060,6 +1113,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSystemSettingsAuthIndexRouteImport parentRoute: typeof AuthenticatedSystemSettingsRouteRoute } + '/_authenticated/model-square/$modelId/': { + id: '/_authenticated/model-square/$modelId/' + path: '/model-square/$modelId' + fullPath: '/model-square/$modelId/' + preLoaderRoute: typeof AuthenticatedModelSquareModelIdIndexRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/system-settings/request-limits/$section': { id: '/_authenticated/system-settings/request-limits/$section' path: '/request-limits/$section' @@ -1196,6 +1256,7 @@ const AuthenticatedSystemSettingsRouteRouteWithChildren = interface AuthenticatedRouteRouteChildren { AuthenticatedSystemSettingsRouteRoute: typeof AuthenticatedSystemSettingsRouteRouteWithChildren AuthenticatedChat2linkRoute: typeof AuthenticatedChat2linkRoute + AuthenticatedStatusMonitorRoute: typeof AuthenticatedStatusMonitorRoute AuthenticatedChatChatIdRoute: typeof AuthenticatedChatChatIdRoute AuthenticatedDashboardSectionRoute: typeof AuthenticatedDashboardSectionRoute AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute @@ -1204,6 +1265,7 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute AuthenticatedKeysIndexRoute: typeof AuthenticatedKeysIndexRoute + AuthenticatedModelSquareIndexRoute: typeof AuthenticatedModelSquareIndexRoute AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute @@ -1212,12 +1274,14 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute AuthenticatedWalletIndexRoute: typeof AuthenticatedWalletIndexRoute + AuthenticatedModelSquareModelIdIndexRoute: typeof AuthenticatedModelSquareModelIdIndexRoute } const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedSystemSettingsRouteRoute: AuthenticatedSystemSettingsRouteRouteWithChildren, AuthenticatedChat2linkRoute: AuthenticatedChat2linkRoute, + AuthenticatedStatusMonitorRoute: AuthenticatedStatusMonitorRoute, AuthenticatedChatChatIdRoute: AuthenticatedChatChatIdRoute, AuthenticatedDashboardSectionRoute: AuthenticatedDashboardSectionRoute, AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute, @@ -1226,6 +1290,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute, AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute, AuthenticatedKeysIndexRoute: AuthenticatedKeysIndexRoute, + AuthenticatedModelSquareIndexRoute: AuthenticatedModelSquareIndexRoute, AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute, AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute, AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute, @@ -1235,6 +1300,8 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedUsageLogsIndexRoute: AuthenticatedUsageLogsIndexRoute, AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute, AuthenticatedWalletIndexRoute: AuthenticatedWalletIndexRoute, + AuthenticatedModelSquareModelIdIndexRoute: + AuthenticatedModelSquareModelIdIndexRoute, } const AuthenticatedRouteRouteWithChildren = diff --git a/web/default/src/routes/_authenticated/model-square/$modelId/index.tsx b/web/default/src/routes/_authenticated/model-square/$modelId/index.tsx new file mode 100644 index 000000000000..41f25cd95c11 --- /dev/null +++ b/web/default/src/routes/_authenticated/model-square/$modelId/index.tsx @@ -0,0 +1,36 @@ +import z from 'zod' +import { createFileRoute } from '@tanstack/react-router' +import { AppHeader, Main } from '@/components/layout' +import { ModelDetails } from '@/features/pricing/components/model-details' + +const modelSquareDetailsSearchSchema = z.object({ + search: z.string().optional(), + sort: z.string().optional(), + vendor: z.string().optional(), + group: z.string().optional(), + quotaType: z.string().optional(), + endpointType: z.string().optional(), + tag: z.string().optional(), + tokenUnit: z.enum(['M', 'K']).optional(), + rechargePrice: z.boolean().optional(), +}) + +export const Route = createFileRoute('/_authenticated/model-square/$modelId/')({ + validateSearch: modelSquareDetailsSearchSchema, + component: ModelSquareDetails, +}) + +function ModelSquareDetails() { + return ( + <> + +
+ +
+ + ) +} diff --git a/web/default/src/routes/_authenticated/model-square/index.tsx b/web/default/src/routes/_authenticated/model-square/index.tsx new file mode 100644 index 000000000000..6d5ea4c1d44f --- /dev/null +++ b/web/default/src/routes/_authenticated/model-square/index.tsx @@ -0,0 +1,37 @@ +import z from 'zod' +import { createFileRoute } from '@tanstack/react-router' +import { AppHeader, Main } from '@/components/layout' +import { Pricing } from '@/features/pricing' + +const modelSquareSearchSchema = z.object({ + search: z.string().optional(), + sort: z.string().optional(), + vendor: z.string().optional(), + group: z.string().optional(), + quotaType: z.string().optional(), + endpointType: z.string().optional(), + tag: z.string().optional(), + tokenUnit: z.enum(['M', 'K']).optional(), + view: z.enum(['list', 'table']).optional(), + rechargePrice: z.boolean().optional(), +}) + +export const Route = createFileRoute('/_authenticated/model-square/')({ + validateSearch: modelSquareSearchSchema, + component: ModelSquare, +}) + +function ModelSquare() { + return ( + <> + +
+ +
+ + ) +} diff --git a/web/default/src/routes/_authenticated/status-monitor.tsx b/web/default/src/routes/_authenticated/status-monitor.tsx new file mode 100644 index 000000000000..f33a5fafda2e --- /dev/null +++ b/web/default/src/routes/_authenticated/status-monitor.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AppHeader, Main } from '@/components/layout' + +const STATUS_MONITOR_URL = 'https://status.tcp.red?sort=serviceType_desc' + +export const Route = createFileRoute('/_authenticated/status-monitor')({ + component: StatusMonitor, +}) + +function StatusMonitor() { + return ( + <> + +
+
+