diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..14af273 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,72 @@ +name: Build & Push Docker Image + +on: + push: + tags: + - 'v*' + branches: + - main + - 'break/**' + - 'release/**' + release: + types: [published] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/printnow/clash-config-store + shine09/clash-config-store + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + type=sha,prefix=sha-,format=short + flavor: | + latest=auto + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VITE_BUILD_LABEL=${{ github.ref_name }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 9a65e15..b680098 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ data/ *.swp *.swo bin/ +.claude/ diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..211f570 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,343 @@ +# Clash Config Store — 前端重设计实施规格 + +> 本文档供 coding agent 使用。 +> 视觉原型:`prototype.html`(可在浏览器直接打开预览) +> 设计决策说明:`ux-design-doc.md` + +--- + +## 0. 技术栈 + +- **前端**:React + TypeScript + Vite + Tailwind CSS + shadcn/ui +- **后端**:Go 1.25 + Gin + GORM + SQLite/MySQL +- **参考原型**:`prototype.html`(单文件,浏览器打开即可交互) + +--- + +## 1. 导航结构变更 + +### 1.1 新侧边栏结构 + +文件:`frontend/src/components/layout/Sidebar.tsx` + +``` +概览 / +─────────────── [来源素材] +节点源 /providers +规则集 /rule-sets ← 原"规则集库"+"托管规则集"合并 +─────────────── [配置编排] +自定义配置 /configs +配置模板 /templates +─────────────── [发布] +订阅管理 /subscriptions +─────────────── +UA 库 /ua-library +设置 /settings +``` + +### 1.2 删除的路由 + +- `/rule-providers`(规则集库) +- `/hosted-rule-sets`(托管规则集) + +两者合并为 `/rule-sets`,用 tab 区分。 + +### 1.3 侧边栏底部流程提示 + +在导航底部(设置之下)渲染一个小卡片,静态文案: + +``` +节点源 → proxy-providers +规则集 → rule-providers +↓ +自定义配置组装 +↓ +订阅链接下发 +``` + +--- + +## 2. 页面变更详情 + +### 2.1 节点源(`/providers`) + +**变更:新增 `inline` 类型支持** + +#### 列表页 + +- 卡片展示三个字段:类型徽标(`http` 蓝色 / `inline` 紫色)、节点数、状态 +- `inline` 类型卡片: + - 不显示 URL,改为显示节点名称 tag 列表(最多展示 3 个,超出显示 +N) + - 无"刷新"按钮,改为"✏️ 编辑节点"按钮 + - 展开详情时显示节点明细表格 + +#### 添加节点源弹窗 + +两种类型卡片式选择(**删除原有的 file 类型**): + +| 类型 | 说明 | +|------|------| +| `http` | 远端订阅 URL,机场订阅 | +| `inline` | 私有节点,手动填写,存入数据库 | + +- 选 `http`:显示 URL、UA、interval、filter、exclude-filter、prefix/suffix、健康检查、override +- 选 `inline`:显示节点列表编辑器(见下方"节点编辑器组件") + +#### 编辑 inline 节点源弹窗 + +- 节点列表(表格)+ "添加节点"按钮 +- 底部展示该 provider 生成的 YAML 片段预览(实时) +- 节点编辑使用通用节点表单(见 2.1 节点表单组件) + +#### 节点表单组件(`ProxyNodeForm`) + +协议下拉切换,字段按协议动态显示: + +| 协议 | 必填字段 | 可选字段 | +|------|----------|----------| +| `ss` | cipher, password | plugin, udp | +| `vmess` | uuid, alterId, cipher | network(tcp/ws/grpc), ws-path, ws-host | +| `vless` | uuid | flow, network | +| `trojan` | password | network | +| `hysteria2` | password | obfs, up, down | +| `tuic` | uuid, password | — | +| `http` | — | username, password | +| `socks5` | — | username, password | + +非 `ss` 协议显示通用 TLS 设置区:tls, sni, skip-cert-verify, fingerprint, alpn + +所有协议共用:name(节点名)、server、port、ip-version、interface-name + +底部折叠入口:**粘贴原始 YAML**(textarea,解析后填入表单;解析失败时以 `__raw__` 字段保存) + +--- + +### 2.2 规则集(`/rule-sets`) + +**变更:原两个页面合并为一个,tab 切换** + +#### Tab 结构 + +``` +全部 (N) | 订阅规则集 (N) | 私有规则集 (N) +``` + +- **订阅规则集**:原"规则集库",type=http,URL 指向第三方 +- **私有规则集**:原"托管规则集",内容存本服务,via `/ruleset/:token/:name` + +#### 列表(表格) + +字段:名称、类型徽标(订阅/私有)、behavior、format、被引用订阅数、操作 + +#### 添加/编辑弹窗 + +第一项选择"来源类型": +- **订阅规则集**:显示 URL、interval、behavior、format、proxy、size-limit、header +- **私有规则集**:显示规则编辑器(文本编辑器,每行一条规则) + +--- + +### 2.3 自定义配置(`/configs`) + +**变更:删除"手动节点"tab,代理组 use[] 支持 inline provider** + +#### Tab 结构 + +``` +代理组 (N) | 规则 (N) | 全局设置 +``` + +删除原"手动节点"tab。 + +#### 代理组编辑 + +`use[]` 字段: +- 以复选框列表展示所有可用 proxy-providers(含 inline 类型) +- inline 类型 provider 显示紫色徽标加 `inline` 小字 + +`proxies[]` 字段: +- 多选,可选项仅包含内置策略(DIRECT、REJECT)和其他代理组名称 +- **不再包含手动节点**(手动节点通过 inline provider → use[] 引入) + +#### 配置切换器 + +顶部 topbar 中的"切换配置"下拉: +- 列出当前用户所有自定义配置(按名称) +- 提供"新建配置"和"复制当前配置"入口 + +--- + +### 2.4 订阅管理(`/subscriptions`) + +**变更:展开面板显示"组成要素"** + +展开一个订阅后,显示四个组成要素块(2×2 grid): + +| 块 | 内容 | +|----|------| +| 📡 节点源 | 已选的 proxy-providers,支持添加/移除 | +| ⚙️ 自定义配置 | 已绑定的 config,支持更换 | +| 📋 规则集 | 从 config 的 RULE-SET 规则自动推断,只读展示 | +| 📄 配置模板 | 已绑定的 template,支持更换 | + +规则集自动推断逻辑:扫描自定义配置的 `rules` 字段,提取所有 `RULE-SET,xxx,...` 的 `xxx`,与规则集库/私有规则集名称匹配后展示。 + +--- + +## 3. 后端 API 变更 + +### 3.1 proxy-providers 新增 inline 类型 + +#### 数据模型变更 + +文件:`internal/model/provider.go`(或现有 provider 相关 model) + +```go +type ProviderType string + +const ( + ProviderTypeHTTP ProviderType = "http" + ProviderTypeInline ProviderType = "inline" + // file 类型移除 +) + +type Provider struct { + Base + UserID uint `gorm:"not null;index"` + Name string `gorm:"not null"` + Type ProviderType `gorm:"not null;default:'http'"` + + // http 类型字段 + URL string `json:"url,omitempty"` + Interval int `json:"interval,omitempty"` + UA string `json:"ua,omitempty"` + Filter string `json:"filter,omitempty"` + ExcludeFilter string `json:"exclude_filter,omitempty"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + HealthCheckURL string `json:"health_check_url,omitempty"` + HealthCheckInterval int `json:"health_check_interval,omitempty"` + OverrideUDP *bool `json:"override_udp,omitempty"` + + // inline 类型字段 + // JSON 序列化的 []map[string]interface{},每项是一个节点 + Payload []map[string]interface{} `gorm:"serializer:json;type:longtext" json:"payload,omitempty"` +} +``` + +#### 新增/修改接口 + +``` +POST /api/providers 创建(支持 type=inline) +PUT /api/providers/:id 更新(支持更新 payload) +GET /api/providers/:id/nodes 获取 inline provider 的节点列表 +POST /api/providers/:id/nodes 向 inline provider 添加节点 +PUT /api/providers/:id/nodes/:nodeIndex 更新 inline 节点 +DELETE /api/providers/:id/nodes/:nodeIndex 删除 inline 节点 +``` + +#### YAML 生成变更 + +文件:`internal/service/yaml_builder.go`(或现有 YAML 生成逻辑) + +inline 类型 provider 生成: + +```yaml +proxy-providers: + 家庭节点: + type: inline + payload: + - name: 家庭 SS + type: ss + server: home.example.com + port: 8388 + cipher: chacha20-ietf-poly1305 + password: "xxx" + udp: true +``` + +http 类型 provider 生成(保持现有逻辑,移除 file 类型分支)。 + +--- + +### 3.2 规则集合并接口 + +将原有的 `rule-providers`(规则集库)和 `hosted-rule-sets`(托管规则集)统一到一套接口,通过 `source_type` 字段区分: + +``` +source_type = "external" → 原规则集库(外部 URL) +source_type = "hosted" → 原托管规则集(本服务托管) +``` + +接口路径建议统一为 `/api/rule-sets`(原两套接口可保留做兼容,前端只调用新接口)。 + +--- + +### 3.3 订阅组成要素接口 + +新增: + +``` +GET /api/subscriptions/:id/components +``` + +返回: + +```json +{ + "providers": [...], // 已选节点源 + "custom_config": {...}, // 已绑定自定义配置 + "rule_sets": [...], // 从配置 rules 推断出的规则集 + "template": {...} // 已绑定模板 +} +``` + +推断规则集的逻辑放在后端:解析 `custom_config.rules` 里的 `RULE-SET,xxx,...`,查找匹配的规则集记录返回。 + +--- + +## 4. 实施优先级 + +| 优先级 | 任务 | +|--------|------| +| P0 | 后端:Provider 模型新增 inline 类型 + 节点 CRUD 接口 | +| P0 | 后端:YAML 生成支持 inline provider(type: inline + payload) | +| P0 | 前端:节点源页面——添加弹窗支持 http/inline 两种类型 | +| P0 | 前端:ProxyNodeForm 组件(协议切换 + 字段联动) | +| P1 | 前端:规则集页面合并(两个页面 → 一个页面 + tab) | +| P1 | 前端:自定义配置删除手动节点 tab,代理组 use[] 显示 inline provider | +| P1 | 前端:订阅管理展示组成要素面板 | +| P2 | 前端:侧边栏更新(新路由 + 流程提示卡片) | +| P2 | 后端:`/api/subscriptions/:id/components` 推断规则集接口 | +| P2 | 后端:移除 file 类型分支,清理相关代码 | + +--- + +## 5. 文件改动清单(前端) + +``` +frontend/src/ +├── components/ +│ ├── layout/Sidebar.tsx 修改:新导航结构 +│ └── proxy/ProxyNodeForm.tsx 新增:协议切换节点表单 +├── pages/ +│ ├── Providers.tsx 修改:新增 inline 类型支持 +│ ├── RuleSets.tsx 新增:合并两个规则集页面 +│ ├── Configs.tsx 修改:删除手动节点 tab,代理组 use[] 更新 +│ └── Subscriptions.tsx 修改:新增组成要素面板 +├── api/ +│ ├── providers.ts 修改:新增 inline 相关接口 +│ └── rule-sets.ts 新增:统一规则集接口 +└── i18n/locales/ + ├── zh.ts 更新:新增文案 key + └── en.ts 更新:新增文案 key +``` + +--- + +## 6. 参考资料 + +- **交互原型**:`prototype.html`(打开后点击侧边栏导航,所有弹窗均可交互) +- **设计决策**:`ux-design-doc.md` +- **Mihomo proxy-providers 文档**:https://wiki.metacubex.one/config/proxy-providers/ +- **Mihomo rule-providers 文档**:https://wiki.metacubex.one/config/rule-providers/ diff --git a/cmd/server/main.go b/cmd/server/main.go index d4276fd..658dc80 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,6 +63,7 @@ func main() { // 公开路由:订阅下发,无需认证 r.GET("/sub/:token", handler.HandleSub) r.GET("/ruleset/:token/:name", handler.HandleRuleSet) + r.GET("/rule-cache/:token", handler.HandleRuleProviderCache) api := r.Group("/api") { @@ -97,6 +98,11 @@ func main() { prov.PUT("/:id", handler.UpdateProvider) prov.DELETE("/:id", handler.DeleteProvider) prov.POST("/:id/refresh", handler.RefreshProvider) + // inline provider 节点管理 + prov.GET("/:id/nodes", handler.GetProviderNodes) + prov.POST("/:id/nodes", handler.AddProviderNode) + prov.PUT("/:id/nodes/:nodeIndex", handler.UpdateProviderNode) + prov.DELETE("/:id/nodes/:nodeIndex", handler.DeleteProviderNode) // 配置模板管理 ct := protected.Group("/config-templates") @@ -106,22 +112,6 @@ func main() { ct.PUT("/:id", handler.UpdateConfigTemplate) ct.DELETE("/:id", handler.DeleteConfigTemplate) - // 规则集库管理 - rp := protected.Group("/rule-providers") - rp.GET("", handler.ListRuleProviders) - rp.POST("", handler.CreateRuleProvider) - rp.GET("/:id", handler.GetRuleProvider) - rp.PUT("/:id", handler.UpdateRuleProvider) - rp.DELETE("/:id", handler.DeleteRuleProvider) - - hrs := protected.Group("/hosted-rule-sets") - hrs.GET("", handler.ListHostedRuleSets) - hrs.POST("", handler.CreateHostedRuleSet) - hrs.GET("/:id", handler.GetHostedRuleSet) - hrs.PUT("/:id", handler.UpdateHostedRuleSet) - hrs.DELETE("/:id", handler.DeleteHostedRuleSet) - hrs.POST("/reset-tokens", handler.ResetHostedRuleSetTokens) - // 自定义配置管理 cc := protected.Group("/custom-configs") cc.GET("", handler.ListCustomConfigs) @@ -133,6 +123,18 @@ func main() { cc.POST("/:id/clone", handler.CloneCustomConfig) cc.GET("/:id/export", handler.ExportCustomConfig) cc.GET("/:id/preview", handler.PreviewCustomConfig) + cc.GET("/:id/history", handler.GetConfigHistories) + cc.POST("/:id/history/:hid/restore", handler.RestoreConfigHistory) + + // 统一规则集管理(外部引用 + 自托管) + rs := protected.Group("/rule-sets") + rs.GET("", handler.ListRuleSets) + rs.POST("", handler.CreateRuleSet) + rs.GET("/:id", handler.GetRuleSet) + rs.PUT("/:id", handler.UpdateRuleSet) + rs.DELETE("/:id", handler.DeleteRuleSet) + rs.POST("/reset-hosted-tokens", handler.ResetHostedRuleSetTokens) + rs.PATCH("/:id/cache-mode", handler.UpdateRuleSetCacheMode) // 订阅管理 sub := protected.Group("/subscriptions") @@ -146,6 +148,17 @@ func main() { sub.GET("/:id/restrictions", handler.ListRestrictions) sub.POST("/:id/restrictions", handler.CreateRestriction) sub.DELETE("/:id/restrictions/:rid", handler.DeleteRestriction) + sub.GET("/:id/components", handler.GetSubscriptionComponents) + + // 管理后台(需管理员权限) + admin := protected.Group("/admin", middleware.Admin()) + admin.GET("/settings", handler.GetSystemSettings) + admin.PUT("/settings", handler.UpdateSystemSettings) + admin.GET("/users", handler.ListAdminUsers) + admin.POST("/users", handler.CreateAdminUser) + admin.GET("/users/:id", handler.GetAdminUser) + admin.PUT("/users/:id", handler.UpdateAdminUser) + admin.DELETE("/users/:id", handler.DeleteAdminUser) } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c219b35..8feecf1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,10 +11,14 @@ import { ConfigTemplates } from '@/pages/ConfigTemplates' import { ConfigTemplateDetail } from '@/pages/ConfigTemplateDetail' import { RuleProviders } from '@/pages/RuleProviders' import { HostedRuleSets } from '@/pages/HostedRuleSets' +import { RuleSets } from '@/pages/RuleSets' import { Subscriptions } from '@/pages/Subscriptions' import { SubscriptionDetail } from '@/pages/SubscriptionDetail' import { AccessLogs } from '@/pages/AccessLogs' import { Settings } from '@/pages/Settings' +import { AdminUsers } from '@/pages/Admin/Users' +import { AdminSettings } from '@/pages/Admin/Settings' +import { AdminRoute } from '@/components/AdminRoute' /** 数据路由(支持 useBlocker 等 API) */ export const router = createBrowserRouter([ @@ -34,10 +38,19 @@ export const router = createBrowserRouter([ { path: 'config-templates/:id', element: }, { path: 'rule-providers', element: }, { path: 'hosted-rule-sets', element: }, + { path: 'rule-sets', element: }, { path: 'subscriptions', element: }, { path: 'subscriptions/:id', element: }, { path: 'subscriptions/:id/logs', element: }, { path: 'settings', element: }, + { + path: 'admin', + element: , + children: [ + { path: 'users', element: }, + { path: 'settings', element: }, + ], + }, ], }, { path: '*', element: }, diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..1bb9c03 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,62 @@ +import client from './client' + +export interface AdminUser { + id: number + name: string + email: string + is_admin: boolean + created_at: string + provider_count: number + subscription_count: number + custom_config_count: number +} + +export interface SystemSettings { + allow_registration: boolean + base_url: string + default_token_expiry_days: number +} + +export const adminApi = { + getSettings: async (): Promise => { + const res = await client.get<{ code: number; data: SystemSettings }>('/admin/settings') + return res.data.data + }, + + updateSettings: async (data: SystemSettings): Promise => { + const res = await client.put<{ code: number; data: SystemSettings }>('/admin/settings', data) + return res.data.data + }, + + listUsers: async (): Promise => { + const res = await client.get<{ code: number; data: AdminUser[] }>('/admin/users') + return res.data.data + }, + + getUser: async (id: number): Promise => { + const res = await client.get<{ code: number; data: AdminUser }>(`/admin/users/${id}`) + return res.data.data + }, + + updateUser: async ( + id: number, + data: { name?: string; email?: string; is_admin?: boolean; password?: string } + ): Promise => { + const res = await client.put<{ code: number; data: AdminUser }>(`/admin/users/${id}`, data) + return res.data.data + }, + + createUser: async (data: { + name: string + email: string + password: string + is_admin: boolean + }): Promise => { + const res = await client.post<{ code: number; data: AdminUser }>('/admin/users', data) + return res.data.data + }, + + deleteUser: async (id: number): Promise => { + await client.delete(`/admin/users/${id}`) + }, +} diff --git a/frontend/src/api/config-history.ts b/frontend/src/api/config-history.ts new file mode 100644 index 0000000..8500ad9 --- /dev/null +++ b/frontend/src/api/config-history.ts @@ -0,0 +1,23 @@ +import client from './client' + +export interface ConfigHistory { + id: number + custom_config_id: number + name: string + proxy_groups: Record[] + rules: string[] + rule_provider_ids: number[] + hosted_rule_set_ids: number[] + created_at: string +} + +export const configHistoryApi = { + list: (configId: number) => + client + .get<{ code: number; data: ConfigHistory[] }>(`/custom-configs/${configId}/history`) + .then((r) => r.data.data), + restore: (configId: number, historyId: number) => + client + .post<{ code: number; data: unknown }>(`/custom-configs/${configId}/history/${historyId}/restore`) + .then((r) => r.data.data), +} diff --git a/frontend/src/api/custom-configs.ts b/frontend/src/api/custom-configs.ts index aa78221..a082529 100644 --- a/frontend/src/api/custom-configs.ts +++ b/frontend/src/api/custom-configs.ts @@ -1,5 +1,5 @@ import client from './client' -import type { CustomConfig, CustomConfigTransferPayload, ProxyNode, ProxyGroup } from '@/types' +import type { CustomConfig, CustomConfigTransferPayload, ProxyGroup } from '@/types' export const customConfigsApi = { list: async (): Promise => { @@ -14,7 +14,6 @@ export const customConfigsApi = { create: async (data: { name: string - proxies?: ProxyNode[] proxy_groups?: ProxyGroup[] rules?: string[] rule_provider_ids?: number[] @@ -28,7 +27,6 @@ export const customConfigsApi = { id: number, data: { name: string - proxies?: ProxyNode[] proxy_groups?: ProxyGroup[] rules?: string[] rule_provider_ids?: number[] diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 78eb310..e5bc617 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -1,45 +1,60 @@ import client from './client' import type { Provider } from '@/types' +type HttpProviderData = { + name: string + type: 'http' + url: string + user_agent_id?: number + cache_ttl?: number + filter?: string + exclude_filter?: string + prefix?: string + suffix?: string +} + +type InlineProviderData = { + name: string + type: 'inline' + payload?: Record[] +} + +type CreateProviderData = HttpProviderData | InlineProviderData + export const providersApi = { - // 获取订阅源列表 list: async (): Promise => { const res = await client.get<{ code: number; data: Provider[] }>('/providers') return res.data.data }, - - // 创建订阅源 - create: async (data: { - name: string - url: string - user_agent_id?: number - cache_ttl?: number - }): Promise => { + create: async (data: CreateProviderData): Promise => { const res = await client.post<{ code: number; data: Provider }>('/providers', data) return res.data.data }, - - // 更新订阅源 - update: async ( - id: number, - data: { - name: string - url: string - user_agent_id?: number - cache_ttl?: number - } - ): Promise => { + update: async (id: number, data: CreateProviderData): Promise => { const res = await client.put<{ code: number; data: Provider }>(`/providers/${id}`, data) return res.data.data }, - - // 删除订阅源 delete: async (id: number): Promise => { await client.delete(`/providers/${id}`) }, - - // 手动刷新订阅源 refresh: async (id: number): Promise => { await client.post(`/providers/${id}/refresh`) }, + // inline provider 节点管理 + getNodes: async (id: number): Promise[]> => { + const res = await client.get<{ code: number; data: Record[] | null }>(`/providers/${id}/nodes`) + return res.data.data ?? [] + }, + addNode: async (id: number, node: Record): Promise[]> => { + const res = await client.post<{ code: number; data: Record[] | null }>(`/providers/${id}/nodes`, node) + return res.data.data ?? [] + }, + updateNode: async (id: number, nodeIndex: number, node: Record): Promise[]> => { + const res = await client.put<{ code: number; data: Record[] | null }>(`/providers/${id}/nodes/${nodeIndex}`, node) + return res.data.data ?? [] + }, + deleteNode: async (id: number, nodeIndex: number): Promise[]> => { + const res = await client.delete<{ code: number; data: Record[] | null }>(`/providers/${id}/nodes/${nodeIndex}`) + return res.data.data ?? [] + }, } diff --git a/frontend/src/api/rule-sets.ts b/frontend/src/api/rule-sets.ts new file mode 100644 index 0000000..ac9f287 --- /dev/null +++ b/frontend/src/api/rule-sets.ts @@ -0,0 +1,42 @@ +import client from './client' +import type { RuleSet } from '@/types' + +type CreateRuleSetData = { + source_type: 'external' | 'hosted' + name: string + behavior: string + format: string + url?: string + interval?: number + content?: string +} + +export const ruleSetsApi = { + list: async (sourceType?: 'external' | 'hosted'): Promise => { + const params = sourceType ? { source_type: sourceType } : {} + const res = await client.get<{ code: number; data: RuleSet[] }>('/rule-sets', { params }) + return res.data.data + }, + create: async (data: CreateRuleSetData): Promise => { + const res = await client.post<{ code: number; data: RuleSet }>('/rule-sets', data) + return res.data.data + }, + update: async (id: number, data: CreateRuleSetData): Promise => { + const res = await client.put<{ code: number; data: RuleSet }>(`/rule-sets/${id}`, data) + return res.data.data + }, + get: async (id: number, sourceType: 'external' | 'hosted'): Promise => { + const res = await client.get<{ code: number; data: RuleSet }>(`/rule-sets/${id}`, { params: { source_type: sourceType } }) + return res.data.data + }, + delete: async (id: number, sourceType: 'external' | 'hosted'): Promise => { + await client.delete(`/rule-sets/${id}`, { params: { source_type: sourceType } }) + }, + resetTokens: async (): Promise => { + await client.post('/rule-sets/reset-hosted-tokens') + }, + updateCacheMode: async (id: number, serverCacheEnabled: boolean): Promise => { + const res = await client.patch<{ code: number; data: RuleSet }>(`/rule-sets/${id}/cache-mode`, { server_cache_enabled: serverCacheEnabled }) + return res.data.data + }, +} diff --git a/frontend/src/api/subscriptions.ts b/frontend/src/api/subscriptions.ts index 8e301d8..4f810a5 100644 --- a/frontend/src/api/subscriptions.ts +++ b/frontend/src/api/subscriptions.ts @@ -1,10 +1,8 @@ import client from './client' -import { withParsedEnabledProviderIds } from '@/domain/subscription/enabledProviderIds' -import type { Subscription, AccessRestriction, AccessLog } from '@/types' +import type { Subscription, AccessRestriction, AccessLog, SubscriptionComponents } from '@/types' interface SubscriptionPayload { name?: string - enabled_provider_ids?: number[] custom_config_id?: number | null config_template_id?: number | null rule_insert_mode?: 'prepend' | 'append' | 'replace' @@ -27,7 +25,7 @@ interface AccessLogResponse { export const subscriptionsApi = { list: async (): Promise => { const res = await client.get<{ code: number; data: Subscription[] }>('/subscriptions') - return res.data.data.map((s) => withParsedEnabledProviderIds(s)) + return res.data.data }, get: async (id: number): Promise => { @@ -35,7 +33,7 @@ export const subscriptionsApi = { code: number data: { subscription: Subscription; access_restrictions: AccessRestriction[] } }>(`/subscriptions/${id}`) - return withParsedEnabledProviderIds(res.data.data.subscription) + return res.data.data.subscription }, getWithRestrictions: async ( @@ -46,19 +44,19 @@ export const subscriptionsApi = { data: { subscription: Subscription; access_restrictions: AccessRestriction[] } }>(`/subscriptions/${id}`) return { - subscription: withParsedEnabledProviderIds(res.data.data.subscription), + subscription: res.data.data.subscription, access_restrictions: res.data.data.access_restrictions, } }, create: async (data: SubscriptionPayload): Promise => { const res = await client.post<{ code: number; data: Subscription }>('/subscriptions', data) - return withParsedEnabledProviderIds(res.data.data) + return res.data.data }, update: async (id: number, data: SubscriptionPayload): Promise => { const res = await client.put<{ code: number; data: Subscription }>(`/subscriptions/${id}`, data) - return withParsedEnabledProviderIds(res.data.data) + return res.data.data }, delete: async (id: number): Promise => { @@ -101,4 +99,9 @@ export const subscriptionsApi = { deleteRestriction: async (subscriptionId: number, restrictionId: number): Promise => { await client.delete(`/subscriptions/${subscriptionId}/restrictions/${restrictionId}`) }, + + getComponents: async (id: number): Promise => { + const res = await client.get<{ code: number; data: SubscriptionComponents }>(`/subscriptions/${id}/components`) + return res.data.data + }, } diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx new file mode 100644 index 0000000..f4ba846 --- /dev/null +++ b/frontend/src/components/AdminRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from 'react-router-dom' +import { useAuthStore } from '@/store/auth' + +export function AdminRoute() { + const { user } = useAuthStore() + + if (!user?.is_admin) { + return + } + + return +} diff --git a/frontend/src/components/HistoryDiffDialog.tsx b/frontend/src/components/HistoryDiffDialog.tsx new file mode 100644 index 0000000..73dd394 --- /dev/null +++ b/frontend/src/components/HistoryDiffDialog.tsx @@ -0,0 +1,148 @@ +import { useMemo } from 'react' +import { diffLines, type Change } from 'diff' +import { useTranslation } from 'react-i18next' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' +import { configPayloadToYaml } from '@/lib/config-payload-yaml' + +export interface HistorySnapshot { + proxy_groups: unknown[] + rules: string[] + rule_provider_ids: number[] + hosted_rule_set_ids: number[] +} + +interface HistoryDiffDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** 更旧的版本(null 表示此条为最早版本) */ + oldSnapshot: HistorySnapshot | null + /** 更新的版本 */ + newSnapshot: HistorySnapshot + /** 显示在标题区的时间信息 */ + savedAt: string +} + +type DiffLine = { kind: 'line'; type: 'added' | 'removed' | 'unchanged'; text: string } +type FoldBlock = { kind: 'fold'; count: number } +type RenderItem = DiffLine | FoldBlock + +const CONTEXT = 3 + +function buildRenderItems(parts: Change[]): RenderItem[] { + const lines: DiffLine[] = [] + for (const part of parts) { + const type: DiffLine['type'] = part.added ? 'added' : part.removed ? 'removed' : 'unchanged' + const texts = part.value.split('\n') + if (texts[texts.length - 1] === '') texts.pop() + for (const text of texts) { + lines.push({ kind: 'line', type, text }) + } + } + + const visible = new Array(lines.length).fill(false) + for (let i = 0; i < lines.length; i++) { + if (lines[i].type !== 'unchanged') { + const lo = Math.max(0, i - CONTEXT) + const hi = Math.min(lines.length - 1, i + CONTEXT) + for (let j = lo; j <= hi; j++) visible[j] = true + } + } + + const items: RenderItem[] = [] + let foldCount = 0 + for (let i = 0; i < lines.length; i++) { + if (visible[i]) { + if (foldCount > 0) { + items.push({ kind: 'fold', count: foldCount }) + foldCount = 0 + } + items.push(lines[i]) + } else { + foldCount++ + } + } + if (foldCount > 0) { + items.push({ kind: 'fold', count: foldCount }) + } + return items +} + +export function HistoryDiffDialog({ + open, + onOpenChange, + oldSnapshot, + newSnapshot, + savedAt, +}: HistoryDiffDialogProps) { + const { t } = useTranslation() + + const { items, hasChange } = useMemo(() => { + if (!oldSnapshot) return { items: [], hasChange: false } + const oldYaml = configPayloadToYaml(oldSnapshot) + const newYaml = configPayloadToYaml(newSnapshot) + if (oldYaml === newYaml) return { items: [], hasChange: false } + const parts = diffLines(oldYaml, newYaml) + return { items: buildRenderItems(parts), hasChange: true } + }, [oldSnapshot, newSnapshot]) + + return ( + + + + {t('configHistory.diffTitle')} + + {t('configHistory.timeLabel')} {savedAt} + + +
+
+ {!oldSnapshot ? ( +

{t('configHistory.diffEarliest')}

+ ) : !hasChange ? ( +

{t('configHistory.diffNoChanges')}

+ ) : ( +
+                {items.map((item, i) => {
+                  if (item.kind === 'fold') {
+                    return (
+                      
+ ··· {t('configHistory.diffFold', { count: item.count })} ··· +
+ ) + } + return ( +
+ + {item.type === 'added' ? '+' : item.type === 'removed' ? '-' : ' '} + + {item.text} +
+ ) + })} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/components/IconPicker.tsx b/frontend/src/components/IconPicker.tsx new file mode 100644 index 0000000..fa2964c --- /dev/null +++ b/frontend/src/components/IconPicker.tsx @@ -0,0 +1,394 @@ +import { useState } from 'react' +import { Image as ImageIcon, X, Search } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +// ── 预设图标库(Koolson/Qure,jsDelivr CDN) ───────────────────────── +const ICON_BASE = 'https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color' + +interface PresetIcon { + id: string + name: string + category: 'policy' | 'region' | 'media' | 'social' | 'tech' | 'gaming' + url: string +} + +const PRESET_ICONS: PresetIcon[] = [ + // 策略 + { id: 'Global', name: '全局', category: 'policy', url: `${ICON_BASE}/Global.png` }, + { id: 'Proxy', name: '代理', category: 'policy', url: `${ICON_BASE}/Proxy.png` }, + { id: 'Auto', name: '自动', category: 'policy', url: `${ICON_BASE}/Auto.png` }, + { id: 'Final', name: '兜底', category: 'policy', url: `${ICON_BASE}/Final.png` }, + { id: 'Reject', name: '拦截', category: 'policy', url: `${ICON_BASE}/Reject.png` }, + { id: 'Direct', name: '直连', category: 'policy', url: `${ICON_BASE}/Direct.png` }, + { id: 'Bypass', name: '绕过', category: 'policy', url: `${ICON_BASE}/Bypass.png` }, + { id: 'Blackhole', name: '黑洞', category: 'policy', url: `${ICON_BASE}/Blackhole.png` }, + { id: 'Streaming', name: '流媒体', category: 'policy', url: `${ICON_BASE}/Streaming.png` }, + { id: 'ForeignMedia', name: '境外媒体', category: 'policy', url: `${ICON_BASE}/ForeignMedia.png` }, + { id: 'DomesticMedia', name: '国内媒体', category: 'policy', url: `${ICON_BASE}/DomesticMedia.png` }, + { id: 'Domestic', name: '国内', category: 'policy', url: `${ICON_BASE}/Domestic.png` }, + // 地区 + { id: 'China', name: '中国', category: 'region', url: `${ICON_BASE}/China.png` }, + { id: 'Japan', name: '日本', category: 'region', url: `${ICON_BASE}/Japan.png` }, + { id: 'Singapore', name: '新加坡', category: 'region', url: `${ICON_BASE}/Singapore.png` }, + { id: 'US', name: '美国', category: 'region', url: `${ICON_BASE}/US.png` }, + { id: 'Korea', name: '韩国', category: 'region', url: `${ICON_BASE}/Korea.png` }, + { id: 'UK', name: '英国', category: 'region', url: `${ICON_BASE}/UK.png` }, + { id: 'Germany', name: '德国', category: 'region', url: `${ICON_BASE}/Germany.png` }, + { id: 'France', name: '法国', category: 'region', url: `${ICON_BASE}/France.png` }, + { id: 'Russia', name: '俄罗斯', category: 'region', url: `${ICON_BASE}/Russia.png` }, + { id: 'India', name: '印度', category: 'region', url: `${ICON_BASE}/India.png` }, + { id: 'Australia', name: '澳大利亚', category: 'region', url: `${ICON_BASE}/Australia.png` }, + { id: 'Canada', name: '加拿大', category: 'region', url: `${ICON_BASE}/Canada.png` }, + { id: 'Brazil', name: '巴西', category: 'region', url: `${ICON_BASE}/Brazil.png` }, + { id: 'Turkey', name: '土耳其', category: 'region', url: `${ICON_BASE}/Turkey.png` }, + { id: 'Thailand', name: '泰国', category: 'region', url: `${ICON_BASE}/Thailand.png` }, + { id: 'Philippines', name: '菲律宾', category: 'region', url: `${ICON_BASE}/Philippines.png` }, + { id: 'Malaysia', name: '马来西亚', category: 'region', url: `${ICON_BASE}/Malaysia.png` }, + { id: 'Macao', name: '澳门', category: 'region', url: `${ICON_BASE}/Macao.png` }, + { id: 'EU', name: '欧盟', category: 'region', url: `${ICON_BASE}/EU.png` }, + // 媒体 + { id: 'YouTube', name: 'YouTube', category: 'media', url: `${ICON_BASE}/YouTube.png` }, + { id: 'YouTube_Music', name: 'YouTube Music', category: 'media', url: `${ICON_BASE}/YouTube_Music.png` }, + { id: 'Netflix', name: 'Netflix', category: 'media', url: `${ICON_BASE}/Netflix.png` }, + { id: 'Spotify', name: 'Spotify', category: 'media', url: `${ICON_BASE}/Spotify.png` }, + { id: 'TikTok', name: 'TikTok', category: 'media', url: `${ICON_BASE}/TikTok.png` }, + { id: 'bilibili', name: 'Bilibili', category: 'media', url: `${ICON_BASE}/bilibili.png` }, + { id: 'iQIYI', name: '爱奇艺', category: 'media', url: `${ICON_BASE}/iQIYI.png` }, + { id: 'Netease_Music', name: '网易云音乐', category: 'media', url: `${ICON_BASE}/Netease_Music.png` }, + { id: 'Disney+', name: 'Disney+', category: 'media', url: `${ICON_BASE}/Disney+.png` }, + { id: 'Twitch', name: 'Twitch', category: 'media', url: `${ICON_BASE}/Twitch.png` }, + { id: 'Prime_Video', name: 'Prime Video', category: 'media', url: `${ICON_BASE}/Prime_Video.png` }, + { id: 'AbemaTV', name: 'AbemaTV', category: 'media', url: `${ICON_BASE}/AbemaTV.png` }, + { id: 'HBO_Max', name: 'HBO Max', category: 'media', url: `${ICON_BASE}/HBO_Max.png` }, + { id: 'Hulu', name: 'Hulu', category: 'media', url: `${ICON_BASE}/Hulu.png` }, + { id: 'Peacock', name: 'Peacock', category: 'media', url: `${ICON_BASE}/Peacock.png` }, + { id: 'ESPN+', name: 'ESPN+', category: 'media', url: `${ICON_BASE}/ESPN+.png` }, + { id: 'DAZN', name: 'DAZN', category: 'media', url: `${ICON_BASE}/DAZN.png` }, + { id: 'TIDAL', name: 'TIDAL', category: 'media', url: `${ICON_BASE}/TIDAL.png` }, + { id: 'deezer', name: 'Deezer', category: 'media', url: `${ICON_BASE}/deezer.png` }, + { id: 'niconico', name: 'niconico', category: 'media', url: `${ICON_BASE}/niconico.png` }, + { id: 'BBC_iPlayer', name: 'BBC iPlayer', category: 'media', url: `${ICON_BASE}/BBC_iPlayer.png` }, + { id: 'Vimeo', name: 'Vimeo', category: 'media', url: `${ICON_BASE}/Vimeo.png` }, + { id: 'Paramount', name: 'Paramount+', category: 'media', url: `${ICON_BASE}/Paramount.png` }, + { id: 'Star+', name: 'Star+', category: 'media', url: `${ICON_BASE}/Star+.png` }, + { id: 'discovery+', name: 'discovery+', category: 'media', url: `${ICON_BASE}/discovery+.png` }, + { id: 'KKBOX', name: 'KKBOX', category: 'media', url: `${ICON_BASE}/KKBOX.png` }, + // 社交 + { id: 'Telegram', name: 'Telegram', category: 'social', url: `${ICON_BASE}/Telegram.png` }, + { id: 'Twitter', name: 'X/Twitter', category: 'social', url: `${ICON_BASE}/Twitter.png` }, + { id: 'Instagram', name: 'Instagram', category: 'social', url: `${ICON_BASE}/Instagram.png` }, + { id: 'Facebook', name: 'Facebook', category: 'social', url: `${ICON_BASE}/Facebook.png` }, + { id: 'Discord', name: 'Discord', category: 'social', url: `${ICON_BASE}/Discord.png` }, + { id: 'WeChat', name: '微信', category: 'social', url: `${ICON_BASE}/WeChat.png` }, + { id: 'Weibo', name: '微博', category: 'social', url: `${ICON_BASE}/Weibo.png` }, + { id: 'Line', name: 'Line', category: 'social', url: `${ICON_BASE}/Line.png` }, + { id: 'Linkedin', name: 'LinkedIn', category: 'social', url: `${ICON_BASE}/Linkedin.png` }, + { id: 'QQ', name: 'QQ', category: 'social', url: `${ICON_BASE}/QQ.png` }, + // 科技 + { id: 'Google', name: 'Google', category: 'tech', url: `${ICON_BASE}/Google.png` }, + { id: 'Gmail', name: 'Gmail', category: 'tech', url: `${ICON_BASE}/Gmail.png` }, + { id: 'Google_Drive', name: 'Google Drive',category: 'tech', url: `${ICON_BASE}/Google_Drive.png` }, + { id: 'Apple', name: 'Apple', category: 'tech', url: `${ICON_BASE}/Apple.png` }, + { id: 'iCloud', name: 'iCloud', category: 'tech', url: `${ICON_BASE}/iCloud.png` }, + { id: 'App_Store', name: 'App Store', category: 'tech', url: `${ICON_BASE}/App_Store.png` }, + { id: 'Microsoft', name: 'Microsoft', category: 'tech', url: `${ICON_BASE}/Microsoft.png` }, + { id: 'OneDrive', name: 'OneDrive', category: 'tech', url: `${ICON_BASE}/OneDrive.png` }, + { id: 'Azure', name: 'Azure', category: 'tech', url: `${ICON_BASE}/Azure.png` }, + { id: 'GitHub', name: 'GitHub', category: 'tech', url: `${ICON_BASE}/GitHub.png` }, + { id: 'ChatGPT', name: 'ChatGPT', category: 'tech', url: `${ICON_BASE}/ChatGPT.png` }, + { id: 'Copilot', name: 'Copilot', category: 'tech', url: `${ICON_BASE}/Copilot.png` }, + { id: 'Amazon', name: 'Amazon', category: 'tech', url: `${ICON_BASE}/Amazon.png` }, + { id: 'Cloudflare', name: 'Cloudflare', category: 'tech', url: `${ICON_BASE}/Cloudflare.png` }, + { id: 'PayPal', name: 'PayPal', category: 'tech', url: `${ICON_BASE}/PayPal.png` }, + { id: 'Notion', name: 'Notion', category: 'tech', url: `${ICON_BASE}/Notion.png` }, + { id: 'Speedtest', name: 'Speedtest', category: 'tech', url: `${ICON_BASE}/Speedtest.png` }, + { id: 'Taobao', name: '淘宝', category: 'tech', url: `${ICON_BASE}/Taobao.png` }, + // 游戏 + { id: 'Steam', name: 'Steam', category: 'gaming', url: `${ICON_BASE}/Steam.png` }, + { id: 'PlayStation', name: 'PlayStation', category: 'gaming', url: `${ICON_BASE}/PlayStation.png` }, + { id: 'Xbox', name: 'Xbox', category: 'gaming', url: `${ICON_BASE}/Xbox.png` }, + { id: 'Nintendo', name: 'Nintendo', category: 'gaming', url: `${ICON_BASE}/Nintendo.png` }, + { id: 'Epic_Games', name: 'Epic Games', category: 'gaming', url: `${ICON_BASE}/Epic_Games.png` }, + { id: 'League_of_Legends', name: 'LoL', category: 'gaming', url: `${ICON_BASE}/League_of_Legends.png` }, +] + +const CATEGORIES = [ + { id: 'all', label: '全部' }, + { id: 'policy', label: '策略' }, + { id: 'region', label: '地区' }, + { id: 'media', label: '媒体' }, + { id: 'social', label: '社交' }, + { id: 'tech', label: '科技' }, + { id: 'gaming', label: '游戏' }, +] as const + +// ── Emoji → SVG data URL ───────────────────────────────────────────── +function emojiToSVGDataURL(emoji: string): string { + const svg = `${emoji}` + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +// ── 预设图标网格 ────────────────────────────────────────────────────── +function PresetGrid({ current, onSelect }: { current: string; onSelect: (url: string) => void }) { + const [search, setSearch] = useState('') + const [category, setCategory] = useState('all') + + const filtered = PRESET_ICONS.filter((icon) => { + const matchCat = category === 'all' || icon.category === category + const matchSearch = !search || icon.name.toLowerCase().includes(search.toLowerCase()) || icon.id.toLowerCase().includes(search.toLowerCase()) + return matchCat && matchSearch + }) + + return ( +
+
+ + setSearch(e.target.value)} + /> +
+
+ {CATEGORIES.map((cat) => ( + + ))} +
+
e.stopPropagation()} + > + +
+ {filtered.map((icon) => ( + + + + + {icon.name} + + ))} + {filtered.length === 0 && ( +
无匹配图标
+ )} +
+
+
+
+ ) +} + +// ── 自定义 URL ──────────────────────────────────────────────────────── +function UrlTab({ current, onSelect }: { current: string; onSelect: (url: string) => void }) { + const [url, setUrl] = useState(() => (current.startsWith('data:') ? '' : current)) + const [imgError, setImgError] = useState(false) + + const isValid = url.startsWith('http://') || url.startsWith('https://') + + return ( +
+
+ { setUrl(e.target.value); setImgError(false) }} + /> +

支持 PNG、JPG、SVG 等格式

+
+ {isValid && !imgError && ( +
+ preview setImgError(true)} + /> + {url} +
+ )} + {imgError && ( +

图片加载失败,请检查 URL

+ )} + +
+ ) +} + +// ── Emoji 标签页 ────────────────────────────────────────────────────── +function EmojiTab({ onSelect }: { onSelect: (dataUrl: string) => void }) { + const [emoji, setEmoji] = useState('') + + const svgUrl = emoji.trim() ? emojiToSVGDataURL(emoji.trim()) : '' + + return ( +
+
+ setEmoji(e.target.value)} + /> +

输入 emoji,直接生成 SVG 图标

+
+ {svgUrl && ( +
+ emoji preview + +
+ )} +
+ ) +} + +// ── 主组件 ──────────────────────────────────────────────────────────── +interface IconPickerProps { + value: string + onChange: (icon: string) => void +} + +export function IconPicker({ value, onChange }: IconPickerProps) { + const [open, setOpen] = useState(false) + const [tab, setTab] = useState('preset') + + const handleSelect = (url: string) => { + onChange(url) + setOpen(false) + } + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + onChange('') + } + + return ( +
+ + + + + +
+ 选择图标 + {value && ( + + )} +
+ + + 预设库 + 自定义 URL + Emoji + + + + + + + + + + + +
+
+ + {/* 当前值预览文字 */} + {value && ( +
+ + {value.startsWith('data:image/svg+xml') ? 'Emoji(SVG)' : value.startsWith('data:') ? 'Emoji' : value} + + +
+ )} + {!value && ( + 点击左侧按钮选择图标 + )} +
+ ) +} diff --git a/frontend/src/components/YamlEditor.tsx b/frontend/src/components/YamlEditor.tsx index 7f75cca..dcf409f 100644 --- a/frontend/src/components/YamlEditor.tsx +++ b/frontend/src/components/YamlEditor.tsx @@ -37,9 +37,17 @@ export interface YamlEditorProps { placeholder?: string /** 编辑器最小高度,如 200px、300px */ minHeight?: string + /** 编辑器固定高度,设置后内容超出时滚动 */ + height?: string + /** 编辑器最大高度,超出后滚动 */ + maxHeight?: string className?: string readOnly?: boolean highlightedLine?: number | null + /** 语言模式,默认 yaml;text 表示纯文本无语言扩展 */ + language?: 'yaml' | 'text' + /** 长行自动换行,默认 false(水平滚动);规则列表场景建议开启 */ + lineWrapping?: boolean } /** YAML 语法高亮编辑区,主题随应用亮/暗/system 切换 */ @@ -47,13 +55,38 @@ export function YamlEditor({ value, onChange, placeholder, - minHeight = '200px', + minHeight, + height, + maxHeight, className, readOnly = false, highlightedLine = null, + language = 'yaml', + lineWrapping = false, }: YamlEditorProps) { + const resolvedMinHeight = minHeight ?? (height ? undefined : '200px') const isDark = useResolvedDark() - const extensions: Extension[] = [yaml()] + + // 用高优先级 theme 覆盖 CodeMirror 内置 light/dark 主题的固定背景色,使其与 app design token 一致 + const appThemeExtension = EditorView.theme( + { + '&': { backgroundColor: 'hsl(var(--background))' }, + '.cm-gutters': { + backgroundColor: 'hsl(var(--muted))', + borderRight: '1px solid hsl(var(--border))', + color: 'hsl(var(--muted-foreground))', + }, + '.cm-activeLineGutter': { backgroundColor: 'hsl(var(--accent))' }, + '.cm-activeLine': { backgroundColor: 'hsl(var(--accent) / 0.3)' }, + }, + { dark: isDark } + ) + + const extensions: Extension[] = [appThemeExtension, ...(language === 'yaml' ? [yaml()] : [])] + + if (lineWrapping) { + extensions.push(EditorView.lineWrapping) + } if (highlightedLine && highlightedLine > 0) { const highlightTheme = EditorView.theme({ @@ -105,10 +138,16 @@ export function YamlEditor({ } return ( -
+
) diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 7472fc6..2715910 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Outlet, Navigate, useNavigate } from 'react-router-dom' +import { Outlet, Navigate, useNavigate, Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { @@ -11,11 +11,13 @@ import { Languages, LogOut, User, + ChevronRight, } from 'lucide-react' import { toast } from 'sonner' import { useAuthStore } from '@/store/auth' import { useThemeStore } from '@/store/theme' import { userApi } from '@/api/user' +import { useBreadcrumbStore } from '@/store/breadcrumb' import { Sidebar } from './Sidebar' import { SidebarBrand } from './SidebarBrand' import { SidebarFooter } from './SidebarFooter' @@ -39,6 +41,7 @@ export function AppLayout() { const { user, token, logout, setAuth } = useAuthStore() const { theme, setTheme } = useThemeStore() const { t, i18n } = useTranslation() + const breadcrumbItems = useBreadcrumbStore((s) => s.items) const navigate = useNavigate() const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState( @@ -187,15 +190,36 @@ export function AppLayout() {
{/* 顶部导航栏 */}
-
+
+ {breadcrumbItems.length > 0 && ( + + )}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c157953..5b64622 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,8 +1,9 @@ import { NavLink } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { LayoutDashboard, Globe, Bot, Settings2, Link, Settings, FileCode2, BookOpen, Cloud } from 'lucide-react' +import { LayoutDashboard, Globe, Bot, Settings2, Link, Settings, FileCode2, BookOpen, ShieldCheck, Users } from 'lucide-react' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useAuthStore } from '@/store/auth' interface NavItem { path: string @@ -10,16 +11,53 @@ interface NavItem { labelKey: string } -const navItems: NavItem[] = [ - { path: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, - { path: '/providers', icon: Globe, labelKey: 'nav.providers' }, - { path: '/custom-configs', icon: Settings2, labelKey: 'nav.customConfigs' }, - { path: '/config-templates', icon: FileCode2, labelKey: 'nav.configTemplates' }, - { path: '/rule-providers', icon: BookOpen, labelKey: 'nav.ruleProviders' }, - { path: '/hosted-rule-sets', icon: Cloud, labelKey: 'nav.hostedRuleSets' }, - { path: '/subscriptions', icon: Link, labelKey: 'nav.subscriptions' }, - { path: '/user-agents', icon: Bot, labelKey: 'nav.userAgents' }, - { path: '/settings', icon: Settings, labelKey: 'nav.settings' }, +interface NavGroup { + label?: string + labelKey?: string + items: NavItem[] + adminOnly?: boolean +} + +const navGroups: NavGroup[] = [ + { + items: [ + { path: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, + ], + }, + { + labelKey: 'nav.groupSource', + items: [ + { path: '/providers', icon: Globe, labelKey: 'nav.providers' }, + { path: '/rule-sets', icon: BookOpen, labelKey: 'nav.ruleSets' }, + ], + }, + { + labelKey: 'nav.groupOrchestration', + items: [ + { path: '/custom-configs', icon: Settings2, labelKey: 'nav.customConfigs' }, + { path: '/config-templates', icon: FileCode2, labelKey: 'nav.configTemplates' }, + ], + }, + { + labelKey: 'nav.groupPublish', + items: [ + { path: '/subscriptions', icon: Link, labelKey: 'nav.subscriptions' }, + ], + }, + { + items: [ + { path: '/user-agents', icon: Bot, labelKey: 'nav.userAgents' }, + { path: '/settings', icon: Settings, labelKey: 'nav.settings' }, + ], + }, + { + labelKey: 'nav.groupAdmin', + adminOnly: true, + items: [ + { path: '/admin/users', icon: Users, labelKey: 'nav.adminUsers' }, + { path: '/admin/settings', icon: ShieldCheck, labelKey: 'nav.adminSettings' }, + ], + }, ] interface SidebarProps { @@ -30,59 +68,86 @@ interface SidebarProps { export function Sidebar({ onNavClick, collapsed = false, labelsVisible = !collapsed }: SidebarProps) { const { t } = useTranslation() + const { user } = useAuthStore() - const navContent = ( -
+ + + + + + + + + + + + + + +
+ + + + diff --git a/ux-design-doc.md b/ux-design-doc.md new file mode 100644 index 0000000..5ebe6a9 --- /dev/null +++ b/ux-design-doc.md @@ -0,0 +1,215 @@ +# Clash Config Store — UI/UX 重设计文档 + +> 版本:v1.0 · 2026-06-13 + +--- + +## 一、核心问题诊断 + +### 现状的三个混乱点 + +| 现状问题 | 根本原因 | +|----------|----------| +| 订阅源、规则集库、托管规则集三个页面割裂 | 没有统一"来源素材"的心智模型,用户不清楚三者都是为最终 YAML 输服务的 | +| 自定义配置的 proxy-groups 编辑与节点源没有视觉关联 | `use: [provider-name]` 这个引用关系在 UI 上不可见 | +| 订阅管理不清楚"装了什么" | 订阅是各部分的组装,但组装关系没有被显性表达 | + +--- + +## 二、新信息架构 + +### 心智模型:三层结构 + +``` +┌─────────────────────────────────────────┐ +│ Layer 1:来源素材 │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ 节点源 │ │ 规则集 │ │ +│ │ proxy-provider│ │ rule-provider │ │ +│ └──────────────┘ └──────────────────┘ │ +└─────────────────┬───────────────────────┘ + ↓ 引用 +┌─────────────────────────────────────────┐ +│ Layer 2:配置编排 │ +│ ┌──────────────────────────────────────┐│ +│ │ 自定义配置 ││ +│ │ proxy-groups (use: [...providers]) ││ +│ │ rules (RULE-SET,xxx,target) ││ +│ └──────────────────────────────────────┘│ +│ ┌──────────────────────────────────────┐│ +│ │ 配置模板(DNS / Tun / 基础设置) ││ +│ └──────────────────────────────────────┘│ +└─────────────────┬───────────────────────┘ + ↓ 组装 +┌─────────────────────────────────────────┐ +│ Layer 3:发布 │ +│ 订阅管理 → /sub/:token → 完整 YAML │ +└─────────────────────────────────────────┘ +``` + +### 导航结构 + +``` +概览(Dashboard) +───────────────── +来源素材 + 📡 节点源 ← 原"订阅源"(proxy-providers) + 📋 规则集 ← 原"规则集库"+"托管规则集" 合并 +───────────────── +配置编排 + ⚙️ 自定义配置 ← tabs: 代理组 | 规则 | 手动节点 | 全局设置 + 📄 配置模板 ← 不变 +───────────────── +发布 + 🔗 订阅管理 ← 原"订阅管理",强化组装关系展示 +───────────────── +🤖 UA 库 +⚙ 设置 +``` + +**关键合并决策:规则集库 + 托管规则集 → 规则集** + +两者本质相同:都是 `rule-providers` 条目,区别仅在托管位置。合并后用"来源类型"徽标区分(外部引用 / 自托管),减少认知负担。 + +--- + +## 三、各页面设计说明 + +### 3.1 节点源(proxy-providers) + +**核心交互变化** + +1. **快速提示条**:页面顶部常驻"更换订阅源的正确流程"提示,降低误操作(如先删后加导致代理组引用断裂)。 + +2. **展开详情**:点击卡片展开,显示过滤规则(filter/exclude-filter)和 override 配置,以及"被引用于哪些订阅"。这让用户在决定删除节点源前,能看到影响范围。 + +3. **刷新状态**:提供明确的在线/过期/刷新中状态,配合"↻ 刷新"快捷操作。 + +**更换节点源的推荐流程(重点 UX 改善)** + +``` +1. 添加新节点源 +2. 进入「自定义配置 → 代理组」,把目标代理组的 use[] 从旧源换成新源 + (或:进入「订阅管理 → 编辑订阅」,直接在组装面板替换) +3. 确认 YAML 预览正确后,删除旧节点源 +``` + +UI 侧通过在订阅管理的"组成要素"面板里提供 **+ 添加/更换** 按钮直接操作,把步骤 1-2 合并。 + +### 3.2 规则集(rule-providers 统一视图) + +**外部引用 vs 自托管** + +| 属性 | 外部引用 | 自托管 | +|------|----------|--------| +| URL 来源 | 第三方(如 GitHub MetaCubeX)| 本服务 `/ruleset/:token/:name` | +| 内容编辑 | 不可编辑(只能更新 URL)| 可在线编辑规则列表 | +| 生成的 YAML | `url: https://raw.github...` | `url: http://yourhost/ruleset/...` | +| 适合场景 | 通用规则集(google/apple/gfw)| 私人规则(家庭网段、工作域名)| + +**关键设计决策**:生成 YAML 时,自托管规则集的 URL 自动填入当前部署的 BASE_URL,用户无需手动维护。 + +### 3.3 自定义配置(三个关键 tab) + +**代理组 Tab** + +每个代理组展开后显示: +- `use[]`:多选的节点源(复选框形式),直接反映 Mihomo `proxy-providers` 引用 +- `proxies[]`:附加固定节点(DIRECT/REJECT/其他组名) +- `filter`/`exclude-filter`:正则过滤,旁边有实时示例提示 + +设计原则:**让 `use` 引用关系可视化**,用户始终知道"这个组的节点来自哪里"。 + +**规则 Tab** + +- 拖拽排序(顺序决定优先级) +- RULE-SET 类型的规则旁边显示小徽标,提示是否已在"规则集"里注册 +- 快速插入:常用模式(DOMAIN-SUFFIX, RULE-SET, GEOIP,CN,DIRECT)一键插入 +- MATCH 兜底规则固定在最底部,不可删除,只能更改目标策略 + +**YAML 预览** + +任何页面都可通过顶部"YAML 预览"按钮打开侧弹窗,实时看到当前配置的生成结果。这解决了"改了不知道有没有生效"的痛点。 + +### 3.4 订阅管理(组装与发布) + +**核心设计思路**:订阅 = 组装声明,不是配置本身。 + +展开一个订阅,显示**组成要素面板**: + +``` +┌─ 组成要素 ─────────────────────────────────┐ +│ 📡 节点源 [ISP-A] [ISP-B] [+ 添加/更换] │ +│ ⚙️ 自定义配置 [special] [更换] │ +│ 📋 规则集 [SZ-Home][MUST-Proxy]…(自动) │ +│ 📄 配置模板 [tun-default] [更换] │ +└────────────────────────────────────────────┘ +``` + +**规则集自动推断**:系统扫描自定义配置中的 `RULE-SET,xxx` 规则,自动列出所需规则集,用户无需手动勾选。如果某个 RULE-SET 引用的名称在规则集库里找不到,会显示警告。 + +--- + +## 四、关键交互模式 + +### 4.1 内联引用关系展示 + +任何地方显示节点源名称或规则集名称,都是可点击的 token,点击后高亮对应资源或跳转。 + +### 4.2 影响范围预警 + +删除节点源 / 规则集前,弹窗显示: +``` +⚠️ 删除「ISP-A 机场」将影响: + - 自定义配置「special」的代理组「Proxy」(use 字段) + - 订阅「主力」「备用」 + +确认删除前,建议先在代理组里替换引用。 +``` + +### 4.3 强制刷新 vs 定时刷新 + +- 定时刷新:由 interval 字段控制,后台自动执行 +- 强制刷新:订阅管理页提供"↻ 强制刷新节点源"按钮,适合临时更新(如机场刚更新了节点) + +--- + +## 五、组件规范 + +### 颜色语义 + +| 颜色 | 含义 | +|------|------| +| 绿色 `#3fb950` | 正常/在线/DIRECT | +| 橙色 `#d29922` | 警告/待刷新/url-test 组 | +| 红色 `#f85149` | 错误/过期/REJECT | +| 蓝色 `#2f81f7` | 交互元素/选中/Proxy 策略 | +| 紫色 `#bc8cff` | 自托管类型标识 | + +### Monospace 字段 + +所有 Mihomo 配置字段值(URL、正则、节点名、规则类型)使用等宽字体,与文案字体视觉区分。 + +### 徽标(Badge)层级 + +``` +[外部引用] [自托管] ← 来源类型 +[在线] [过期] [刷新中] ← 状态 +[2 个订阅] [1 个配置] ← 引用计数 +``` + +--- + +## 六、实现优先级建议 + +| 优先级 | 功能点 | +|--------|--------| +| P0 | 规则集库 + 托管规则集合并为一个页面 | +| P0 | 订阅管理展示"组成要素"(节点源/配置/模板) | +| P0 | 删除资源前显示影响范围弹窗 | +| P1 | 代理组 `use[]` 字段用复选框选择节点源 | +| P1 | YAML 预览随时可访问(顶部按钮) | +| P1 | 侧边栏底部显示数据流向提示 | +| P2 | 规则集引用自动推断(从 RULE-SET 规则扫描) | +| P2 | 拖拽排序规则/代理组 | +| P2 | 快速插入常用规则模板 |