Compare commits

..
170 changed files with 2781 additions and 8337 deletions
+133
View File
@@ -0,0 +1,133 @@
```markdown
# certd Development Patterns
> Auto-generated skill from repository analysis
## Overview
This skill teaches the core development patterns and workflows for the `certd` TypeScript codebase. It covers coding conventions, file organization, commit patterns, and detailed step-by-step instructions for extending user preferences—a common feature workflow. The repository is structured for modularity, with clear separation between UI and backend logic, and emphasizes maintainable, convention-driven development.
## Coding Conventions
### File Naming
- **CamelCase** is used for file names.
- Example: `userPreferences.ts`, `preferencesDrawer.vue`
### Import Style
- **Absolute imports** are preferred.
- Example:
```typescript
import { getUserPreferences } from 'packages/ui/certd-client/src/vben/layouts/widgets/preferences/api';
```
### Export Style
- **Named exports** are used throughout the codebase.
- Example:
```typescript
export function getUserPreferences() { ... }
export const PREFERENCE_KEYS = [ ... ];
```
### Commit Patterns
- **Conventional commits** are used, with the `feat` prefix for new features.
- Example:
```
feat: add account sync to preferences
```
## Workflows
### Extend Preferences Feature
**Trigger:** When someone wants to add or enhance a user preference feature (e.g., import/export, sync to account).
**Command:** `/extend-preferences`
Follow these steps to extend user preferences functionality:
1. **Update localization files**
Add or modify strings in:
- `packages/ui/certd-client/src/locales/langs/en-US/preferences.ts`
- `packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts`
```typescript
// en-US/preferences.ts
export default {
sync: "Sync Preferences",
import: "Import Preferences",
// ...
};
```
2. **Modify or add Vue components for preferences UI**
Update or create components such as:
- `preferences-drawer.vue`
- `account-sync.ts`
```vue
<!-- preferences-drawer.vue -->
<template>
<div>
<button @click="syncPreferences">{{ $t('preferences.sync') }}</button>
</div>
</template>
```
3. **Update or add supporting icon definitions**
Edit:
- `packages/ui/certd-client/src/vben/icons/lucide.ts`
```typescript
export const SyncIcon = { /* icon definition */ };
```
4. **Implement or update store logic for settings**
Update:
- `packages/ui/certd-client/src/store/settings/index.tsx`
```typescript
export function syncPreferencesToAccount() { ... }
```
5. **Add or update backend API/controller for user preferences**
Edit or add:
- `packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts`
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.ts`
```typescript
// user-preferences.ts
export async function updateUserPreferences(req, res) { ... }
```
6. **Write or update backend tests for new preference logic**
Add or update:
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts`
```typescript
test('should sync preferences', async () => { ... });
```
7. **Update backend models if necessary**
Edit:
- `packages/ui/certd-server/src/modules/mine/service/models.ts`
```typescript
export interface UserPreferences { ... }
```
## Testing Patterns
- **Test files** follow the `*.test.*` naming convention.
- Example: `user-preferences.test.ts`
- **Testing framework** is not explicitly detected, but tests are written in TypeScript and likely use a standard Node.js testing library (e.g., Jest or Mocha).
- **Test Example:**
```typescript
test('should update preferences', async () => {
// Arrange
// Act
// Assert
});
```
## Commands
| Command | Purpose |
|----------------------|--------------------------------------------------------------|
| /extend-preferences | Guide to extend or enhance user preference functionality |
```
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Certd"
short_description: "Repo-specific patterns and workflows for certd"
default_prompt: "Use the certd repo skill to follow existing architecture, testing, and workflow conventions."
policy:
allow_implicit_invocation: true
+227
View File
@@ -0,0 +1,227 @@
{
"version": "1.3",
"schemaVersion": "1.0",
"generatedBy": "ecc-tools",
"generatedAt": "2026-07-30T02:07:44.046Z",
"repo": "https://github.com/certd/certd",
"referenceSetReadiness": {
"score": 0,
"present": 0,
"total": 7,
"items": [
{
"id": "deep-analyzer-corpus",
"label": "Deep analyzer corpus",
"status": "missing",
"evidence": [],
"recommendation": "Add analyzer fixture, golden, benchmark, or reference-set files that can catch analyzer regressions."
},
{
"id": "rag-evaluator",
"label": "RAG/evaluator comparison",
"status": "missing",
"evidence": [],
"recommendation": "Add retrieval or evaluator reference-set comparison fixtures with expected ranking behavior."
},
{
"id": "pr-salvage",
"label": "PR salvage/review corpus",
"status": "missing",
"evidence": [],
"recommendation": "Add stale-PR, review-thread, reopen-flow, or salvage reference cases for queue cleanup automation."
},
{
"id": "discussion-triage",
"label": "Discussion triage corpus",
"status": "missing",
"evidence": [],
"recommendation": "Add public discussion triage fixtures, golden cases, or reference sets for informational, answered, and no-response classifications."
},
{
"id": "harness-compatibility",
"label": "Harness compatibility",
"status": "missing",
"evidence": [],
"recommendation": "Add cross-harness, adapter-compliance, or harness-audit evidence for Claude, Codex, OpenCode, Zed, dmux, and agent surfaces."
},
{
"id": "security-evidence",
"label": "Security evidence",
"status": "missing",
"evidence": [],
"recommendation": "Attach security evidence such as SBOMs, SARIF, audit reports, or AgentShield evidence packs."
},
{
"id": "ci-failure-mode",
"label": "CI failure-mode evidence",
"status": "missing",
"evidence": [],
"recommendation": "Add captured CI failure logs, dry-run fixtures, or troubleshooting docs for common workflow failure modes."
}
]
},
"profiles": {
"requested": "core",
"recommended": "core",
"effective": "core",
"requestedAlias": "core",
"recommendedAlias": "core",
"effectiveAlias": "core"
},
"requestedProfile": "core",
"profile": "core",
"recommendedProfile": "core",
"effectiveProfile": "core",
"tier": "free",
"requestedComponents": [
"repo-baseline"
],
"selectedComponents": [
"repo-baseline"
],
"requestedAddComponents": [],
"requestedRemoveComponents": [],
"blockedRemovalComponents": [],
"tierFilteredComponents": [],
"requestedRootPackages": [
"runtime-core"
],
"selectedRootPackages": [
"runtime-core"
],
"requestedPackages": [
"runtime-core"
],
"requestedAddPackages": [],
"requestedRemovePackages": [],
"selectedPackages": [
"runtime-core"
],
"packages": [
"runtime-core"
],
"blockedRemovalPackages": [],
"tierFilteredRootPackages": [],
"tierFilteredPackages": [],
"conflictingPackages": [],
"dependencyGraph": {
"runtime-core": []
},
"resolutionOrder": [
"runtime-core"
],
"requestedModules": [
"runtime-core"
],
"selectedModules": [
"runtime-core"
],
"modules": [
"runtime-core"
],
"managedFiles": [
".claude/skills/certd/SKILL.md",
".agents/skills/certd/SKILL.md",
".agents/skills/certd/agents/openai.yaml",
".claude/identity.json",
".codex/config.toml",
".codex/AGENTS.md",
".codex/agents/explorer.toml",
".codex/agents/reviewer.toml",
".codex/agents/docs-researcher.toml",
".claude/homunculus/instincts/inherited/certd-instincts.yaml"
],
"packageFiles": {
"runtime-core": [
".claude/skills/certd/SKILL.md",
".agents/skills/certd/SKILL.md",
".agents/skills/certd/agents/openai.yaml",
".claude/identity.json",
".codex/config.toml",
".codex/AGENTS.md",
".codex/agents/explorer.toml",
".codex/agents/reviewer.toml",
".codex/agents/docs-researcher.toml",
".claude/homunculus/instincts/inherited/certd-instincts.yaml"
]
},
"moduleFiles": {
"runtime-core": [
".claude/skills/certd/SKILL.md",
".agents/skills/certd/SKILL.md",
".agents/skills/certd/agents/openai.yaml",
".claude/identity.json",
".codex/config.toml",
".codex/AGENTS.md",
".codex/agents/explorer.toml",
".codex/agents/reviewer.toml",
".codex/agents/docs-researcher.toml",
".claude/homunculus/instincts/inherited/certd-instincts.yaml"
]
},
"files": [
{
"moduleId": "runtime-core",
"path": ".claude/skills/certd/SKILL.md",
"description": "Repository-specific Claude Code skill generated from git history."
},
{
"moduleId": "runtime-core",
"path": ".agents/skills/certd/SKILL.md",
"description": "Codex-facing copy of the generated repository skill."
},
{
"moduleId": "runtime-core",
"path": ".agents/skills/certd/agents/openai.yaml",
"description": "Codex skill metadata so the repo skill appears cleanly in the skill interface."
},
{
"moduleId": "runtime-core",
"path": ".claude/identity.json",
"description": "Suggested identity.json baseline derived from repository conventions."
},
{
"moduleId": "runtime-core",
"path": ".codex/config.toml",
"description": "Repo-local Codex MCP and multi-agent baseline aligned with ECC defaults."
},
{
"moduleId": "runtime-core",
"path": ".codex/AGENTS.md",
"description": "Codex usage guide that points at the generated repo skill and workflow bundle."
},
{
"moduleId": "runtime-core",
"path": ".codex/agents/explorer.toml",
"description": "Read-only explorer role config for Codex multi-agent work."
},
{
"moduleId": "runtime-core",
"path": ".codex/agents/reviewer.toml",
"description": "Read-only reviewer role config focused on correctness and security."
},
{
"moduleId": "runtime-core",
"path": ".codex/agents/docs-researcher.toml",
"description": "Read-only docs researcher role config for API verification."
},
{
"moduleId": "runtime-core",
"path": ".claude/homunculus/instincts/inherited/certd-instincts.yaml",
"description": "Continuous-learning instincts derived from repository patterns."
}
],
"workflows": [],
"adapters": {
"claudeCode": {
"skillPath": ".claude/skills/certd/SKILL.md",
"identityPath": ".claude/identity.json",
"commandPaths": []
},
"codex": {
"configPath": ".codex/config.toml",
"agentsGuidePath": ".codex/AGENTS.md",
"skillPath": ".agents/skills/certd/SKILL.md"
}
}
}
@@ -0,0 +1,486 @@
# Instincts generated from https://github.com/certd/certd
# Generated: 2026-07-30T02:08:05.402Z
# Version: 2.0
# NOTE: This file supplements (does not replace) any existing curated instincts.
# High-confidence manually curated instincts should be preserved alongside these.
---
id: certd-commit-conventional
trigger: "when writing a commit message"
confidence: 0.85
domain: git
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Commit Conventional
## Action
Use conventional commit format with prefixes: feat
## Evidence
- 2 commits analyzed
- Detected conventional commit pattern
- Examples: feat: 偏好设置支持从剪切板导入, feat: 偏好设置支持保存到账号并在登录后自动同步
---
id: certd-commit-length
trigger: "when writing a commit message"
confidence: 0.6
domain: git
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Commit Length
## Action
Keep commit messages concise (~22 characters)
## Evidence
- Average commit message length: 22 chars
- Based on 2 commits
---
id: certd-naming-files
trigger: "when creating a new file"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Naming Files
## Action
Use camelCase naming convention
## Evidence
- Analyzed file naming patterns in repository
- Dominant pattern: camelCase
---
id: certd-export-style
trigger: "when exporting from a module"
confidence: 0.7
domain: code-style
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Export Style
## Action
Prefer named exports
## Evidence
- Export pattern analysis
- Dominant style: named
---
id: certd-arch-feature-based
trigger: "when adding a new feature"
confidence: 0.85
domain: architecture
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Arch Feature Based
## Action
Create a new folder in src/features/ with all related code colocated
## Evidence
- Feature-based module organization detected
- Structure: src/features/[feature-name]/
---
id: certd-workflow-extend-preferences-feature
trigger: "when doing extend preferences feature"
confidence: 0.6
domain: workflow
source: repo-analysis
source_repo: https://github.com/certd/certd
---
# Certd Workflow Extend Preferences Feature
## Action
Follow the extend-preferences-feature workflow:
1. Update localization files for new preference-related strings
2. Modify or add Vue components for preferences UI
3. Update or add supporting icon definitions
4. Implement or update store logic for settings
5. Add or update backend API/controller for user preferences
6. Write or update backend tests for new preference logic
7. Update backend models if necessary
## Evidence
- Workflow detected from commit patterns
- Frequency: ~2x per month
- Files: packages/ui/certd-client/src/locales/langs/en-US/preferences.ts, packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts, packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue
---
id: certd-instinct-file-naming
trigger: "When creating a new file in the codebase"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct File Naming
## Action
Name the file using camelCase
## Evidence
- Pattern in codeStyle.namingConventions.files
---
id: certd-instinct-function-naming
trigger: "When defining a new function"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Function Naming
## Action
Use camelCase for function names
## Evidence
- Pattern in codeStyle.namingConventions.functions
---
id: certd-instinct-class-naming
trigger: "When defining a new class"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Class Naming
## Action
Use PascalCase for class names
## Evidence
- Pattern in codeStyle.namingConventions.classes
---
id: certd-instinct-constant-naming
trigger: "When declaring a constant"
confidence: 0.9
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Constant Naming
## Action
Use SCREAMING_SNAKE_CASE for constant names
## Evidence
- Pattern in codeStyle.namingConventions.constants
---
id: certd-instinct-import-style
trigger: "When importing modules"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Import Style
## Action
Use absolute import paths
## Evidence
- Pattern in codeStyle.importStyle
---
id: certd-instinct-export-style
trigger: "When exporting from a module"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Export Style
## Action
Use named exports
## Evidence
- Pattern in codeStyle.exportStyle
---
id: certd-instinct-try-catch
trigger: "When handling errors in code"
confidence: 0.8
domain: code-style
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Try Catch
## Action
Use try-catch blocks for error handling
## Evidence
- Pattern in errorHandling.style
---
id: certd-instinct-test-location
trigger: "When adding or updating tests"
confidence: 0.7
domain: testing
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Test Location
## Action
Place tests in both source and test-specific folders as appropriate (mixed location)
## Evidence
- Pattern in architecture.folderStructure.testLocation
- Seen in files like packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts
---
id: certd-instinct-backend-test-pattern
trigger: "When adding backend logic for user preferences"
confidence: 0.8
domain: testing
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Backend Test Pattern
## Action
Create or update a corresponding .test.ts file in the same directory
## Evidence
- Seen in packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts
---
id: certd-instinct-conventional-commits
trigger: "When writing a commit message"
confidence: 0.9
domain: git
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Conventional Commits
## Action
Use the conventional commit format with a type prefix (e.g., feat: ...)
## Evidence
- Pattern in commits.type
- Examples: feat: 偏好设置支持从剪切板导入
---
id: certd-instinct-commit-length
trigger: "When writing a commit message"
confidence: 0.7
domain: git
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Commit Length
## Action
Keep the commit message concise, around 22 characters on average
## Evidence
- Pattern in commits.averageLength
---
id: certd-instinct-extend-preferences-localization
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Localization
## Action
Update localization files for new preference-related strings
## Evidence
- Workflow: extend-preferences-feature
- Files: packages/ui/certd-client/src/locales/langs/en-US/preferences.ts, packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts
---
id: certd-instinct-extend-preferences-ui
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Ui
## Action
Modify or add Vue components for the preferences UI
## Evidence
- Workflow: extend-preferences-feature
- Files: packages/ui/certd-client/src/vben/layouts/widgets/preferences/preferences-drawer.vue
---
id: certd-instinct-extend-preferences-icons
trigger: "When extending or adding a user preference feature"
confidence: 0.8
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Icons
## Action
Update or add supporting icon definitions
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-client/src/vben/icons/lucide.ts
---
id: certd-instinct-extend-preferences-store
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Store
## Action
Implement or update store logic for settings
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-client/src/store/settings/index.tsx
---
id: certd-instinct-extend-preferences-backend-api
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Backend Api
## Action
Add or update backend API/controller for user preferences
## Evidence
- Workflow: extend-preferences-feature
- Files: packages/ui/certd-server/src/controller/user/mine/user-preferences.ts, packages/ui/certd-server/src/controller/user/mine/user-settings-controller.ts
---
id: certd-instinct-extend-preferences-backend-test
trigger: "When extending or adding a user preference feature"
confidence: 0.9
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Backend Test
## Action
Write or update backend tests for new preference logic
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts
---
id: certd-instinct-extend-preferences-models
trigger: "When extending or adding a user preference feature and new data is needed"
confidence: 0.8
domain: workflow
source: repo-analysis
source_repo: certd/certd
---
# Certd Instinct Extend Preferences Models
## Action
Update backend models if necessary
## Evidence
- Workflow: extend-preferences-feature
- File: packages/ui/certd-server/src/modules/mine/service/models.ts
+14
View File
@@ -0,0 +1,14 @@
{
"version": "2.0",
"technicalLevel": "technical",
"preferredStyle": {
"verbosity": "detailed",
"codeComments": true,
"explanations": true
},
"domains": [
"typescript"
],
"suggestedBy": "ecc-tools-repo-analysis",
"createdAt": "2026-07-30T02:08:05.402Z"
}
+133
View File
@@ -0,0 +1,133 @@
```markdown
# certd Development Patterns
> Auto-generated skill from repository analysis
## Overview
This skill teaches the core development patterns and workflows for the `certd` TypeScript codebase. It covers coding conventions, file organization, commit patterns, and detailed step-by-step instructions for extending user preferences—a common feature workflow. The repository is structured for modularity, with clear separation between UI and backend logic, and emphasizes maintainable, convention-driven development.
## Coding Conventions
### File Naming
- **CamelCase** is used for file names.
- Example: `userPreferences.ts`, `preferencesDrawer.vue`
### Import Style
- **Absolute imports** are preferred.
- Example:
```typescript
import { getUserPreferences } from 'packages/ui/certd-client/src/vben/layouts/widgets/preferences/api';
```
### Export Style
- **Named exports** are used throughout the codebase.
- Example:
```typescript
export function getUserPreferences() { ... }
export const PREFERENCE_KEYS = [ ... ];
```
### Commit Patterns
- **Conventional commits** are used, with the `feat` prefix for new features.
- Example:
```
feat: add account sync to preferences
```
## Workflows
### Extend Preferences Feature
**Trigger:** When someone wants to add or enhance a user preference feature (e.g., import/export, sync to account).
**Command:** `/extend-preferences`
Follow these steps to extend user preferences functionality:
1. **Update localization files**
Add or modify strings in:
- `packages/ui/certd-client/src/locales/langs/en-US/preferences.ts`
- `packages/ui/certd-client/src/locales/langs/zh-CN/preferences.ts`
```typescript
// en-US/preferences.ts
export default {
sync: "Sync Preferences",
import: "Import Preferences",
// ...
};
```
2. **Modify or add Vue components for preferences UI**
Update or create components such as:
- `preferences-drawer.vue`
- `account-sync.ts`
```vue
<!-- preferences-drawer.vue -->
<template>
<div>
<button @click="syncPreferences">{{ $t('preferences.sync') }}</button>
</div>
</template>
```
3. **Update or add supporting icon definitions**
Edit:
- `packages/ui/certd-client/src/vben/icons/lucide.ts`
```typescript
export const SyncIcon = { /* icon definition */ };
```
4. **Implement or update store logic for settings**
Update:
- `packages/ui/certd-client/src/store/settings/index.tsx`
```typescript
export function syncPreferencesToAccount() { ... }
```
5. **Add or update backend API/controller for user preferences**
Edit or add:
- `packages/ui/certd-client/src/vben/layouts/widgets/preferences/api.ts`
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.ts`
```typescript
// user-preferences.ts
export async function updateUserPreferences(req, res) { ... }
```
6. **Write or update backend tests for new preference logic**
Add or update:
- `packages/ui/certd-server/src/controller/user/mine/user-preferences.test.ts`
```typescript
test('should sync preferences', async () => { ... });
```
7. **Update backend models if necessary**
Edit:
- `packages/ui/certd-server/src/modules/mine/service/models.ts`
```typescript
export interface UserPreferences { ... }
```
## Testing Patterns
- **Test files** follow the `*.test.*` naming convention.
- Example: `user-preferences.test.ts`
- **Testing framework** is not explicitly detected, but tests are written in TypeScript and likely use a standard Node.js testing library (e.g., Jest or Mocha).
- **Test Example:**
```typescript
test('should update preferences', async () => {
// Arrange
// Act
// Assert
});
```
## Commands
| Command | Purpose |
|----------------------|--------------------------------------------------------------|
| /extend-preferences | Guide to extend or enhance user preference functionality |
```
+26
View File
@@ -0,0 +1,26 @@
# ECC for Codex CLI
This supplements the root `AGENTS.md` with a repo-local ECC baseline.
## Repo Skill
- Repo-generated Codex skill: `.agents/skills/certd/SKILL.md`
- Claude-facing companion skill: `.claude/skills/certd/SKILL.md`
- Keep user-specific credentials and private MCPs in `~/.codex/config.toml`, not in this repo.
## MCP Baseline
Treat `.codex/config.toml` as the default ECC-safe baseline for work in this repository.
The generated baseline enables GitHub, Context7, Exa, Memory, Playwright, and Sequential Thinking.
## Multi-Agent Support
- Explorer: read-only evidence gathering
- Reviewer: correctness, security, and regression review
- Docs researcher: API and release-note verification
## Workflow Files
- No dedicated workflow command files were generated for this repo.
Use these workflow files as reusable task scaffolds when the detected repository workflows recur.
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.4"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Verify APIs, framework behavior, and release-note claims against primary documentation before changes land.
Cite the exact docs or file paths that support each claim.
Do not invent undocumented behavior.
"""
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.4"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Stay in exploration mode.
Trace the real execution path, cite files and symbols, and avoid proposing fixes unless the parent agent asks for them.
Prefer targeted search and file reads over broad scans.
"""
+9
View File
@@ -0,0 +1,9 @@
model = "gpt-5.4"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Review like an owner.
Prioritize correctness, security, behavioral regressions, and missing tests.
Lead with concrete findings and avoid style-only feedback unless it hides a real bug.
"""
+48
View File
@@ -0,0 +1,48 @@
#:schema https://developers.openai.com/codex/config-schema.json
# ECC Tools generated Codex baseline
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
[mcp_servers.context7]
command = "npx"
args = ["-y", "@upstash/context7-mcp@latest"]
[mcp_servers.exa]
url = "https://mcp.exa.ai/mcp"
[mcp_servers.memory]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-memory"]
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--extension"]
[mcp_servers.sequential-thinking]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
[features]
multi_agent = true
[agents]
max_threads = 6
max_depth = 1
[agents.explorer]
description = "Read-only codebase explorer for gathering evidence before changes are proposed."
config_file = "agents/explorer.toml"
[agents.reviewer]
description = "PR reviewer focused on correctness, security, and missing tests."
config_file = "agents/reviewer.toml"
[agents.docs_researcher]
description = "Documentation specialist that verifies APIs, framework behavior, and release notes."
config_file = "agents/docs-researcher.toml"
+4 -8
View File
@@ -105,10 +105,8 @@ jobs:
tags: | tags: |
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}-slim registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}-slim
registry.cn-shenzhen.aliyuncs.com/certd/certd:slim greper/certd:slim
registry.cn-shenzhen.aliyuncs.com/certd/certd:${{steps.get_certd_version.outputs.result}}-slim greper/certd:${{steps.get_certd_version.outputs.result}}-slim
certd/certd:slim
certd/certd:${{steps.get_certd_version.outputs.result}}-slim
ghcr.io/${{ github.repository }}:slim ghcr.io/${{ github.repository }}:slim
ghcr.io/${{ github.repository }}:${{steps.get_certd_version.outputs.result}}-slim ghcr.io/${{ github.repository }}:${{steps.get_certd_version.outputs.result}}-slim
@@ -121,10 +119,8 @@ jobs:
tags: | tags: |
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7 registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}-armv7 registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}-armv7
registry.cn-shenzhen.aliyuncs.com/certd/certd:armv7 greper/certd:armv7
registry.cn-shenzhen.aliyuncs.com/certd/certd:${{steps.get_certd_version.outputs.result}}-armv7 greper/certd:${{steps.get_certd_version.outputs.result}}-armv7
certd/certd:armv7
certd/certd:${{steps.get_certd_version.outputs.result}}-armv7
ghcr.io/${{ github.repository }}:armv7 ghcr.io/${{ github.repository }}:armv7
ghcr.io/${{ github.repository }}:${{steps.get_certd_version.outputs.result}}-armv7 ghcr.io/${{ github.repository }}:${{steps.get_certd_version.outputs.result}}-armv7
-88
View File
@@ -1,88 +0,0 @@
name: stable-release
on:
workflow_dispatch:
inputs:
version:
description: "版本号(如 v1.42.5"
required: true
type: string
permissions:
contents: read
packages: write
jobs:
make-stable:
runs-on: ubuntu-latest
steps:
- 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: Login to aliyun container Registry
uses: docker/login-action@v3
with:
registry: registry.cn-shenzhen.aliyuncs.com
username: ${{ secrets.aliyun_cs_username }}
password: ${{ secrets.aliyun_cs_password }}
- name: Login to GitHub Packages
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.dockerhub_username }}
password: ${{ secrets.dockerhub_password }}
# stable 镜像:叠加 ENV 层
- name: Build and push stable images
run: |
echo "FROM greper/certd:${{ inputs.version }}
ENV certd_release_mode=stable" > Dockerfile.stable
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-f Dockerfile.stable \
-t registry.cn-shenzhen.aliyuncs.com/handsfree/certd:stable \
-t registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{ inputs.version }}-stable \
-t registry.cn-shenzhen.aliyuncs.com/certd/certd:stable \
-t registry.cn-shenzhen.aliyuncs.com/certd/certd:${{ inputs.version }}-stable \
-t certd/certd:stable \
-t certd/certd:${{ inputs.version }}-stable \
-t ghcr.io/${{ github.repository }}:stable \
-t ghcr.io/${{ github.repository }}:${{ inputs.version }}-stable \
.
# slim-stable 镜像
- name: Build and push slim-stable images
run: |
echo "FROM greper/certd:${{ inputs.version }}-slim
ENV certd_release_mode=stable" > Dockerfile.slim-stable
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-f Dockerfile.slim-stable \
-t registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim-stable \
-t registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{ inputs.version }}-slim-stable \
-t registry.cn-shenzhen.aliyuncs.com/certd/certd:slim-stable \
-t registry.cn-shenzhen.aliyuncs.com/certd/certd:${{ inputs.version }}-slim-stable \
-t certd/certd:slim-stable \
-t certd/certd:${{ inputs.version }}-slim-stable \
-t ghcr.io/${{ github.repository }}:slim-stable \
-t ghcr.io/${{ github.repository }}:${{ inputs.version }}-slim-stable \
.
- name: Set AtomGit release as stable
run: |
export ATOMGIT_TOKEN=${{ secrets.ATOMGIT_TOKEN }}
export VERSION=${{ inputs.version }}
npm run set-release-stable
@@ -0,0 +1,412 @@
# 插件依赖按需加载方案
## 背景与目标
### 当前问题
- `packages/ui/certd-server/node_modules` 包含 50+ 个插件的所有依赖,体积庞大
- 大量云厂商 SDK(AWS、阿里云、腾讯云、华为云等)只在特定插件中使用
- 用户通常只使用少数几个插件,但必须安装所有依赖
### 目标
实现依赖的按需下载和加载:
1. 插件依赖独立管理,不占用主 `node_modules` 空间
2. 只有当用户首次使用某插件时,才动态下载该插件需要的依赖
3. 依赖安装完成后,通过 `await import()` 从独立路径加载
4. 保持现有插件代码的最小改动
## 当前架构分析
### 插件加载机制
- 插件位于 `packages/ui/certd-server/src/plugins/` 下(50+ 个插件目录)
- `AutoLoadPlugins` 类在启动时扫描 `dist/plugins` 目录并动态导入
- 插件注册到不同的 registry`accessRegistry`, `pluginRegistry`, `dnsProviderRegistry`
- 插件代码已经使用 `await import()` 进行懒加载(如 `await import("@aws-sdk/client-acm")`
### 重型依赖分布
`packages/ui/certd-server/package.json` 分析,以下依赖体积大且仅特定插件使用:
**云厂商 SDK(按插件分组):**
- **AWS 插件**`@aws-sdk/client-acm`, `@aws-sdk/client-cloudfront`, `@aws-sdk/client-iam`, `@aws-sdk/client-route-53`, `@aws-sdk/client-s3`, `@aws-sdk/client-sts`
- **阿里云插件**`@alicloud/openapi-client`, `@alicloud/pop-core`, `@alicloud/tea-typescript`, `@alicloud/fc20230330`
- **腾讯云插件**`tencentcloud-sdk-nodejs`, `cos-nodejs-sdk-v5`
- **华为云插件**`@huaweicloud/huaweicloud-sdk-cdn`, `@huaweicloud/huaweicloud-sdk-core`
- **Azure 插件**`@azure/arm-dns`, `@azure/identity`
- **Google Cloud 插件**`@google-cloud/dns`, `@google-cloud/publicca`
- **火山引擎插件**`@volcengine/openapi`, `@volcengine/tos-sdk`
**网络/工具库:**
- `ssh2`, `socks`, `socks-proxy-agent`SSH 相关插件)
- `ali-oss`, `qiniu`, `basic-ftp`(存储/传输插件)
- `nodemailer`(邮件通知插件)
**通用依赖(保留在主 package.json):**
- `@midwayjs/*` 系列(框架核心)
- `@certd/*` 系列(项目内部包)
- `axios`, `lodash-es`, `dayjs`, `js-yaml` 等基础工具
## 设计方案
### 架构概览
```
packages/ui/certd-server/
├── package.json # 主依赖(框架、通用工具)
├── node_modules/ # 主依赖安装目录
├── optional-deps/ # 新增:可选依赖管理目录
│ ├── package.json # 可选依赖总配置(用于 pnpm install
│ ├── pnpm-lock.yaml # 可选依赖锁文件
│ └── node_modules/ # 可选依赖安装目录
├── src/
│ └── modules/
│ └── dependency/ # 新增:依赖管理模块
│ ├── dependency-manager.ts # 核心:依赖管理器
│ ├── dependency-registry.ts # 依赖注册表(插件 -> 依赖映射)
│ └── types.ts # 类型定义
```
### 核心组件
#### 1. 依赖管理器(DependencyManager
**职责:**
- 检查依赖是否已安装
- 动态执行 `pnpm install` 安装缺失依赖
- 提供从 `optional-deps/node_modules` 加载依赖的方法
- 并发控制:避免多个插件同时触发安装
**关键方法:**
```typescript
class DependencyManager {
// 确保依赖已安装,返回依赖模块
async ensureAndImport<T>(packageName: string): Promise<T>
// 检查依赖是否已安装
async isInstalled(packageName: string): Promise<boolean>
// 安装依赖(带锁,避免并发)
async installDependencies(packages: string[]): Promise<void>
// 从 optional-deps/node_modules 加载依赖
async loadModule<T>(packageName: string): Promise<T>
}
```
**实现要点:**
- 使用文件锁(如 `proper-lockfile`)防止并发安装
- 安装前检查 `optional-deps/node_modules/{packageName}` 是否存在
- 安装命令:`pnpm install --dir optional-deps --ignore-workspace`
- 加载时使用绝对路径:`import('file:///absolute/path/to/optional-deps/node_modules/package')`
#### 2. 依赖注册表(DependencyRegistry
**职责:**
- 维护插件名称到依赖列表的映射
- 提供依赖查询接口
**数据结构:**
```typescript
interface PluginDependencyConfig {
pluginName: string;
dependencies: {
packageName: string;
version: string;
optional?: boolean; // 是否可选(安装失败不阻塞)
}[];
}
// 示例注册
dependencyRegistry.register('plugin-aws', [
{ packageName: '@aws-sdk/client-acm', version: '^3.964.0' },
{ packageName: '@aws-sdk/client-cloudfront', version: '^3.964.0' },
{ packageName: '@aws-sdk/client-route-53', version: '^3.964.0' },
]);
```
#### 3. 插件集成
**改造现有插件代码:**
改造前(`plugin-aws/libs/aws-client.ts`):
```typescript
const { ACMClient, ImportCertificateCommand } = await import("@aws-sdk/client-acm");
```
改造后:
```typescript
import { DependencyManager } from "../../../modules/dependency/dependency-manager.js";
const depManager = new DependencyManager();
const { ACMClient, ImportCertificateCommand } = await depManager.ensureAndImport("@aws-sdk/client-acm");
```
**简化方案(推荐):**
创建辅助函数,减少改动量:
```typescript
// src/modules/dependency/import-helper.ts
export async function importOptionalDep<T>(packageName: string): Promise<T> {
const depManager = new DependencyManager();
return await depManager.ensureAndImport<T>(packageName);
}
// 插件中使用
import { importOptionalDep } from "../../../modules/dependency/import-helper.js";
const { ACMClient } = await importOptionalDep("@aws-sdk/client-acm");
```
### 实施步骤
#### 阶段一:基础设施搭建
1. 创建 `optional-deps/` 目录结构
2. 生成 `optional-deps/package.json`(包含所有可选依赖)
3. 实现 `DependencyManager` 核心逻辑
4. 实现依赖安装锁机制
5. 编写单元测试
#### 阶段二:依赖迁移
6. 从主 `package.json` 移除可选依赖
7. 将依赖添加到 `optional-deps/package.json`
8. 创建依赖注册表,映射插件到依赖
#### 阶段三:插件改造
9. 创建 `import-helper.ts` 辅助函数
10. 逐步改造插件代码,使用 `importOptionalDep` 加载依赖
11. 优先改造重型依赖(AWS、阿里云、腾讯云等)
#### 阶段四:测试与优化
12. 端到端测试:验证依赖按需安装和加载
13. 性能优化:缓存已加载的模块
14. 错误处理:安装失败时的降级策略
15. 文档:编写使用说明和迁移指南
## 关键技术决策
### 1. 依赖分组策略
**选择:按插件分组**
- 每个插件声明自己需要的依赖
- 优点:职责清晰,易于维护
- 缺点:可能有重复依赖(但 pnpm 会去重)
**备选:按功能分组**
- 将依赖按功能分组(如 "aws-deps", "aliyun-deps"
- 优点:更细粒度控制
- 缺点:增加复杂度
### 2. 安装触发时机
**选择:首次使用时触发**
- 在插件的 `execute()``getClient()` 方法中触发安装
- 优点:真正的按需加载
- 缺点:首次使用有延迟
**备选:启动时预检查**
- 启动时扫描启用的插件,预安装依赖
- 优点:避免运行时延迟
- 缺点:可能安装不需要的依赖
### 3. 依赖路径解析
**选择:使用绝对路径 + `file://` 协议**
```typescript
const modulePath = path.resolve(__dirname, '../../optional-deps/node_modules', packageName);
return await import(`file://${modulePath}/index.js`);
```
**原因:**
- Node.js ESM 要求明确的 URL 格式
- 避免模块解析冲突
### 4. 并发控制
**选择:文件锁 + 内存锁双重保护**
- 使用 `proper-lockfile` 锁定 `optional-deps/` 目录
- 内存中使用 `Map` 记录正在安装的依赖
- 避免多个插件同时触发安装
### 5. 错误处理
**策略:**
- 安装失败时记录日志,抛出明确的错误信息
- 提供手动安装命令提示:`请运行: cd optional-deps && pnpm install`
- 支持降级:某些非核心依赖安装失败时,插件可以部分功能可用
## 验证方案
### 单元测试
1. 测试 `DependencyManager.isInstalled()` 正确检测依赖状态
2. 测试 `DependencyManager.installDependencies()` 成功安装依赖
3. 测试并发安装时的锁机制
4. 测试从 `optional-deps/node_modules` 加载模块
### 集成测试
1. 清空 `optional-deps/node_modules`
2. 启动服务,验证不触发安装
3. 调用 AWS 插件,验证触发安装并成功加载
4. 再次调用,验证不重复安装
5. 验证主 `node_modules` 体积减少
### 性能测试
1. 测量首次安装依赖的耗时
2. 测量后续加载的耗时(应该与正常 import 相近)
3. 对比改造前后的 `node_modules` 大小
## 风险与挑战
### 1. 首次使用延迟
**风险:** 用户首次使用插件时需要等待依赖安装(可能几十秒)
**缓解:**
- 在 UI 上显示安装进度
- 提供预安装命令:`pnpm run install-optional-deps`
- 文档说明首次使用会有延迟
### 2. 离线环境
**风险:** 离线环境无法下载依赖
**缓解:**
- 提供完整安装包(包含所有可选依赖)
- 支持手动复制 `node_modules`
### 3. 版本冲突
**风险:** 可选依赖与主依赖版本冲突
**缓解:**
- 使用 `--ignore-workspace` 隔离安装
- 定期同步主依赖版本
### 4. TypeScript 类型
**风险:** 动态导入的类型推断
**缓解:**
- 保留 `@types/*` 在主 `devDependencies`
- 使用泛型和类型断言
## 预期收益
1. **空间节省:**`node_modules` 体积减少 60-70%(估算)
2. **安装速度:** 初始 `pnpm install` 速度提升 3-5 倍
3. **用户体验:** 不使用的插件不占用空间,按需加载
4. **维护性:** 依赖分组清晰,易于管理
## 后续优化
1. **依赖预热:** 在后台预安装常用插件依赖
2. **依赖缓存:** 支持从 CDN 或本地缓存安装
3. **依赖更新:** 提供命令批量更新可选依赖
4. **插件市场:** 支持从远程下载插件及其依赖配置
## 附录:依赖分类清单
### 可选依赖(迁移到 optional-deps/package.json
**AWS 相关(plugin-aws, plugin-aws-cn):**
```json
{
"@aws-sdk/client-acm": "^3.964.0",
"@aws-sdk/client-cloudfront": "^3.964.0",
"@aws-sdk/client-iam": "^3.964.0",
"@aws-sdk/client-route-53": "^3.964.0",
"@aws-sdk/client-s3": "^3.964.0",
"@aws-sdk/client-sts": "^3.990.0"
}
```
**阿里云相关(plugin-aliyun, plugin-lib/aliyun):**
```json
{
"@alicloud/fc20230330": "^4.1.7",
"@alicloud/openapi-client": "^0.4.12",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/pop-core": "^1.7.10",
"@alicloud/sts-sdk": "^1.0.2",
"@alicloud/tea-typescript": "^1.8.0",
"@alicloud/tea-util": "^1.4.10",
"ali-oss": "^6.21.0"
}
```
**腾讯云相关(plugin-tencent, plugin-lib/tencent):**
```json
{
"tencentcloud-sdk-nodejs": "^4.1.112",
"cos-nodejs-sdk-v5": "^2.14.6"
}
```
**华为云相关(plugin-huawei):**
```json
{
"@huaweicloud/huaweicloud-sdk-cdn": "3.1.185",
"@huaweicloud/huaweicloud-sdk-core": "3.1.185",
"@huaweicloud/huaweicloud-sdk-elb": "3.1.185",
"@huaweicloud/huaweicloud-sdk-iam": "3.1.185",
"esdk-obs-nodejs": "^3.25.6"
}
```
**Azure 相关(plugin-azure):**
```json
{
"@azure/arm-dns": "^5.1.0",
"@azure/identity": "^4.13.1"
}
```
**Google Cloud 相关(plugin-google, plugin-cert/google):**
```json
{
"@google-cloud/dns": "^5.3.1",
"@google-cloud/publicca": "^1.3.0"
}
```
**火山引擎相关(plugin-volcengine):**
```json
{
"@volcengine/openapi": "^1.28.1",
"@volcengine/tos-sdk": "^2.9.1"
}
```
**SSH/网络相关(plugin-host, plugin-lib/ssh):**
```json
{
"ssh2": "^1.17.0",
"socks": "^2.8.3",
"socks-proxy-agent": "^8.0.4",
"basic-ftp": "^5.0.5"
}
```
**其他存储/传输(plugin-qiniu, plugin-lib/qiniu):**
```json
{
"qiniu": "^7.12.0"
}
```
**邮件通知(plugin-notification/email):**
```json
{
"nodemailer": "^6.9.16"
}
```
### 主依赖(保留在主 package.json
**框架核心:**
- `@midwayjs/*` 系列
- `@koa/cors`
- `typeorm`, `better-sqlite3`, `mysql2`, `pg`
**项目内部包:**
- `@certd/*` 系列
**通用工具:**
- `axios`, `lodash-es`, `dayjs`, `js-yaml`
- `crypto-js`, `jsonwebtoken`, `bcryptjs`
- `reflect-metadata`, `uuid`, `nanoid`
- 等等
## 总结
本方案通过引入独立的可选依赖管理机制,实现了插件依赖的按需下载和加载。核心思路是:
1. **隔离管理:**`optional-deps/` 目录下维护独立的 `package.json``node_modules`
2. **动态安装:** 通过 `DependencyManager` 在首次使用时触发 `pnpm install`
3. **路径加载:** 使用绝对路径从独立目录加载依赖模块
4. **最小改动:** 通过辅助函数 `importOptionalDep` 简化插件代码改造
该方案可以显著减少主 `node_modules` 体积,提升初始安装速度,同时保持现有架构的兼容性和可维护性。
-14
View File
@@ -87,20 +87,6 @@
"plus_use_prod": "false", "plus_use_prod": "false",
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007" "PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
} }
},
{
"name": "server-local-comm",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/packages/ui/certd-server",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev-localcomm"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"plus_use_prod": "false",
"PLUS_SERVER_BASE_URL": "http://127.0.0.1:11007"
}
} }
], ],
"compounds": [ "compounds": [
-18
View File
@@ -139,9 +139,6 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
- 列表管理、后台管理、记录查询、CRUD 表格页面优先使用 Fast Crud;开发或重构前读 `.trae/skills/fast-crud-page-dev/SKILL.md` - 列表管理、后台管理、记录查询、CRUD 表格页面优先使用 Fast Crud;开发或重构前读 `.trae/skills/fast-crud-page-dev/SKILL.md`
- 只有轻量只读展示、强交互自定义界面或既有页面模式明显不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明。 - 只有轻量只读展示、强交互自定义界面或既有页面模式明显不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明。
- 内嵌 Fast Crud 时,外层必须有稳定高度或完整 `flex: 1; min-height: 0` 链路。 - 内嵌 Fast Crud 时,外层必须有稳定高度或完整 `flex: 1; min-height: 0` 链路。
- 前端组件样式统一写在 `<style>` / Less / CSS 文件里,通过样式名映射到元素;尽量不要在元素上直接写 `style`
- 每个组件都要有一个稳定的根样式名,并把组件下方样式全部包在该根样式名内;尽量不要使用 `scoped`
- 可复用的公共样式名放在 `packages/ui/certd-client/src/style` 下维护,优先使用 `cd-` 前缀,避免散落在业务组件里重复定义。
- 后台管理列表展示或筛选用户字段时,优先参考 `packages/ui/certd-client/src/views/sys/suite/user-suite/crud.tsx``userId` 字段模式,用 `table-select` + `/sys/authority/user/getSimpleUserByIds` 字典回显和搜索。 - 后台管理列表展示或筛选用户字段时,优先参考 `packages/ui/certd-client/src/views/sys/suite/user-suite/crud.tsx``userId` 字段模式,用 `table-select` + `/sys/authority/user/getSimpleUserByIds` 字典回显和搜索。
- 对话框里只做确认可用 `Modal.confirm`;有字段输入、表单校验或提交字段时,必须用 `useFormDialog` / `openFormDialog` - 对话框里只做确认可用 `Modal.confirm`;有字段输入、表单校验或提交字段时,必须用 `useFormDialog` / `openFormDialog`
@@ -220,18 +217,3 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
### 旧版数据兼容 ### 旧版数据兼容
- 新增插件参数时,必须要考虑旧版数据兼容,比如新增一个deployType参数,有两种值:`default``custom`,需要在使用时判空,走旧版逻辑。 - 新增插件参数时,必须要考虑旧版数据兼容,比如新增一个deployType参数,有两种值:`default``custom`,需要在使用时判空,走旧版逻辑。
## 前端路由与国际化
- 路由 `meta.title`**i18n 国际化 key**,必须在 `src/locales/langs/zh-CN/``src/locales/langs/en-US/` 对应的模块文件中添加翻译。
- 示例:路由 `title: "certd.auditLog"` 需要在中英 locales 文件中有对应 key`"certd.auditLog": "操作日志"` / `"certd.auditLog": "Audit Log"`)。
- 菜单通过路由自动生成,需设置 `meta.isMenu: true` 才会出现在左侧菜单。
- Plus 版功能菜单需设置 `meta.show: () => { const settingStore = useSettingStore(); return settingStore.isPlus; }`
## 审计日志
- 审计日志是 Plus 版功能,非 Plus 版不会写入。
- Controller 继承 `BaseController`,通过 `this.auditLog({ content: "xxx" })` 记录日志。
- Controller 中的 `@Post("/add", { summary: "xxxx" })`, 这个summary是必须要的,他是日志action字段的来源
- `getAuditType()` 返回类型常量,中间件自动从 ctx.path 判定 scope`/api/sys/` → system,其他 → user)。
- 操作日志有系统级(scope=system)和用户级(scope=user)区分。
+19 -42
View File
@@ -1,4 +1,4 @@
# Certd # Certd
中文 | [English](./README_en.md) 中文 | [English](./README_en.md)
@@ -105,56 +105,33 @@ https://certd.handfree.work/
#### Docker镜像说明: #### Docker镜像说明:
##### 1. 镜像地址格式: **镜像版本:**
```
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型
```
##### 2. 版本标签:
**最新版本标签:**
| 版本 | 标签 | 说明 |
| --- | --- | --- |
| 最新预览版【默认】 | `certd:latest` | 指向最新开发版本,包含最新功能,但稳定性不如稳定版 |
| 最新稳定版 | `certd:stable` | 指向经过充分测试的生产就绪版本,推荐生产环境使用 |
**系统分支版本:**
> 根据基础镜像不同,分为如下三个分支版本,没有特殊需求选择默认的即可(他们功能是一样的)
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 |
| --- | --- | --- | --- | --- |
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小 | `certd:stable` |
| slim | `certd:slim` | Debian slim | 基于glibcdns解析兼容性好 | `certd:slim-stable` |
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` |
##### 2. 镜像地址:
| 镜像仓库 | 最新预览版 | slim | armv7 |
| --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
| Docker Hub | `greper/certd:latest` | `certd:slim` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
> 注意: | 标签 | 指定版本 | 基础系统 | 说明 |
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。 | --- | --- | --- | --- |
> 2. 稳定版在后面加 `-stable` 即可。 | `latest` | `[version]` | Alpine Linux | 默认版本,镜像体积小 |
> 3. 如需指定具体的版本号,在冒号后面加 `version-`即可,例如 `certd:1.42.1-stable`。 | `slim` | `[version]-slim` | Debian slim | 基于glibcdns解析兼容性好(可能需要配置security_opt -seccomp=unconfined |
| `armv7` | `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
##### 3. 镜像构建说明: **镜像地址:**
| 镜像仓库 | latest | slim | armv7 |
| --- | --- | --- | --- |
| 阿里云 | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7` |
| Docker Hub | `greper/certd:latest` | `greper/certd:slim` | `greper/certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `ghcr.io/certd/certd:slim` | `ghcr.io/certd/certd:armv7` |
> 带版本号的标签请将 `latest` / `slim` / `armv7` 替换为 `[version]` / `[version]-slim` / `[version]-armv7`
- 镜像构建通过`Actions`自动执行,过程公开透明,请放心使用 - 镜像构建通过`Actions`自动执行,过程公开透明,请放心使用
- [点我查看预览版构建日志](https://github.com/certd/certd/actions/workflows/release-image.yml) - [点我查看镜像构建日志](https://github.com/certd/certd/actions/workflows/build-image.yml)
- [点我查看稳定版发布日志](https://github.com/certd/certd/actions/workflows/stable-release.yml)
![](./docs/images/action/action-build.jpg) ![](./docs/images/action/action-build.jpg)
##### 4. 安全注意事项 > 注意
>
> - 本应用存储的证书、授权信息等属于高度敏感数据,请做好安全防护 > - 本应用存储的证书、授权信息等属于高度敏感数据,请做好安全防护
> - 请务必使用HTTPS协议访问本应用,避免被中间人攻击 > - 请务必使用HTTPS协议访问本应用,避免被中间人攻击
> - 请务必使用web应用防火墙防护本应用,防止XSS、SQL注入等攻击 > - 请务必使用web应用防火墙防护本应用,防止XSS、SQL注入等攻击
+13 -36
View File
@@ -1,4 +1,4 @@
# Certd # Certd
[中文](./README.md) | English [中文](./README.md) | English
@@ -95,44 +95,21 @@ You can choose one of the following deployment methods based on your needs:
#### Docker Image Information: #### Docker Image Information:
**Release channels:** - Domestic Image Addresses:
- `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest`
- `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7`, `[version]-armv7`
- DockerHub Addresses:
- `https://hub.docker.com/r/greper/certd`
- `greper/certd:latest`
- `greper/certd:armv7`, `greper/certd:[version]-armv7`
- GitHub Packages Addresses:
| Channel | Description | - `ghcr.io/certd/certd:latest`
| --- | --- | - `ghcr.io/certd/certd:armv7`, `ghcr.io/certd/certd:[version]-armv7`
| `stable` / `slim-stable` | **Stable version**, production-ready and fully tested, recommended for production environments |
| `latest` / `slim` / `armv7` | **Preview version**, latest development build with newest features but potentially less stable |
**Image tags:**
| Channel | Tag | Versioned Tag | Base System | Description |
| --- | --- | --- | --- | --- |
| **Stable** | `stable` | `[version]-stable` | Alpine Linux | Recommended for production |
| | `slim-stable` | `[version]-slim-stable` | Debian slim | Better DNS resolution compatibility |
| **Preview** | `latest` | `[version]` | Alpine Linux | Default, small image size |
| | `slim` | `[version]-slim` | Debian slim | Better DNS resolution compatibility |
| | `armv7` | `[version]-armv7` | Alpine Linux | ARMv7 architecture |
**Stable version image addresses:**
| Registry | `stable` | `slim-stable` |
| --- | --- | --- |
| Aliyun | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:stable` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim-stable` |
| Docker Hub | `greper/certd:stable` | `greper/certd:slim-stable` |
| GitHub Packages | `ghcr.io/certd/certd:stable` | `ghcr.io/certd/certd:slim-stable` |
**Preview version image addresses:**
| Registry | `latest` | `slim` | `armv7` |
| --- | --- | --- | --- |
| Aliyun | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:slim` | `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:armv7` |
| Docker Hub | `greper/certd:latest` | `greper/certd:slim` | `greper/certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `ghcr.io/certd/certd:slim` | `ghcr.io/certd/certd:armv7` |
> For versioned tags, replace tag name with `[version]-tag`, e.g. replace `stable` with `[version]-stable`
- Images are built automatically by `Actions`, with a transparent process. Please use them with confidence. - Images are built automatically by `Actions`, with a transparent process. Please use them with confidence.
- [Click here to view preview version build logs](https://github.com/certd/certd/actions/workflows/release-image.yml) - [Click here to view image build logs](https://github.com/certd/certd/actions/workflows/build-image.yml)
- [Click here to view stable version release logs](https://github.com/certd/certd/actions/workflows/stable-release.yml)
![](./docs/images/action/action-build.jpg) ![](./docs/images/action/action-build.jpg)
> Note: > Note:
+1 -5
View File
@@ -2,13 +2,9 @@ version: '3.3' # 兼容旧版docker-compose
services: services:
certd: certd:
# 镜像 # ↓↓↓↓↓ ---- 镜像版本号,建议改成固定版本号,例如:certd:1.29.0 # 镜像 # ↓↓↓↓↓ ---- 镜像版本号,建议改成固定版本号,例如:certd:1.29.0
image: registry.cn-shenzhen.aliyuncs.com/certd/certd:latest image: registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
# image: ghcr.io/certd/certd:latest # --------- 如果 报镜像not found,可以尝试其他镜像源 # image: ghcr.io/certd/certd:latest # --------- 如果 报镜像not found,可以尝试其他镜像源
# image: greper/certd:latest # image: greper/certd:latest
# --------- 生产建议使用稳定版, latest改成stable即可
# image: registry.cn-shenzhen.aliyuncs.com/certd/certd:stable
# security_opt: # --------- 如果slim镜像下启动报错,尝试去掉这两行注释 # security_opt: # --------- 如果slim镜像下启动报错,尝试去掉这两行注释
# - seccomp=unconfined # 解决slim镜像下WorkerThreadsTaskRunner::DelayedTaskScheduler::Start() 报错问题 # - seccomp=unconfined # 解决slim镜像下WorkerThreadsTaskRunner::DelayedTaskScheduler::Start() 报错问题
container_name: certd # 容器名 container_name: certd # 容器名
+6 -15
View File
@@ -6,22 +6,13 @@
Certd 提供多种 Docker 镜像版本,您可以根据需要选择: Certd 提供多种 Docker 镜像版本,您可以根据需要选择:
**最新版本:** | 版本标签 | 基础系统 | 说明 |
| --- | --- | --- | --- |
| `latest` / `[version]` | Alpine Linux | 默认版本,镜像体积小 |
| `slim` / `[version]-slim` | Debian slim | glibc版本,dns解析兼容性更好(可能需要配置security_opt -seccomp=unconfined|
| `armv7` / `[version]-armv7` | Alpine Linux | ARMv7 架构专用版本 |
| 版本 | 标签 | 说明 | > 如果您不确定使用哪个版本,请使用默认的 `latest` 版本。
| --- | --- | --- |
| 预览版【默认】 | `certd:latest` | 指向最新开发版本,包含最新功能,但稳定性不如稳定版 |
| 稳定版 | `certd:stable` | 指向经过充分测试的生产就绪版本,推荐生产环境使用 |
**系统版本分支:**
| 分支版本标签 | 基础系统 | 说明 | 指定版本 | 稳定版 |
| --- | --- | --- | --- | --- |
| `certd:latest` 【默认】 | Alpine Linux | 默认版本,镜像体积小 | `certd:[version]` | `certd:[version-]stable` |
| `certd:slim` | Debian slim | glibc版本,dns解析兼容性更好(可能需要配置security_opt -seccomp=unconfined| `certd:[version-]slim` | `certd:[version-]slim-stable` |
| `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:[version]-armv7` | `certd:[version-]armv7-ststable` |
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
### 一键脚本安装(推荐) ### 一键脚本安装(推荐)
+2 -4
View File
@@ -19,7 +19,7 @@
"devb": "lerna run dev-build", "devb": "lerna run dev-build",
"i-all": "lerna link && lerna exec npm install ", "i-all": "lerna link && lerna exec npm install ",
"publish": "pnpm run prepublishOnly2 && lerna publish --force-publish=pro/plus-core --conventional-commits && pnpm run afterpublishOnly ", "publish": "pnpm run prepublishOnly2 && lerna publish --force-publish=pro/plus-core --conventional-commits && pnpm run afterpublishOnly ",
"publish2": " npm run pub_all && pnpm run afterpublishOnly", "publish2":" npm run pub_all && pnpm run afterpublishOnly",
"afterpublishOnly": "pnpm run copylogs && time /t >trigger/build.trigger && git add ./trigger/build.trigger && git commit -m \"build: trigger build image\" && TIMEOUT /T 10 && pnpm run commitAll", "afterpublishOnly": "pnpm run copylogs && time /t >trigger/build.trigger && git add ./trigger/build.trigger && git commit -m \"build: trigger build image\" && TIMEOUT /T 10 && pnpm run commitAll",
"transform-sql": "cd ./packages/ui/certd-server/db/ && node --experimental-json-modules transform.js", "transform-sql": "cd ./packages/ui/certd-server/db/ && node --experimental-json-modules transform.js",
"plugin-doc-gen": "cd ./packages/ui/certd-server/ && pnpm run export-metadata", "plugin-doc-gen": "cd ./packages/ui/certd-server/ && pnpm run export-metadata",
@@ -45,9 +45,7 @@
"publish_to_atomgit": "node --experimental-json-modules ./scripts/publish-atomgit.js", "publish_to_atomgit": "node --experimental-json-modules ./scripts/publish-atomgit.js",
"publish_to_gitee": "node --experimental-json-modules ./scripts/publish-gitee.js", "publish_to_gitee": "node --experimental-json-modules ./scripts/publish-gitee.js",
"publish_to_github": "node --experimental-json-modules ./scripts/publish-github.js", "publish_to_github": "node --experimental-json-modules ./scripts/publish-github.js",
"get_version": "node --experimental-json-modules ./scripts/version.js", "get_version": "node --experimental-json-modules ./scripts/version.js"
"stable": "node ./scripts/stable.js",
"set-release-stable": "node ./scripts/set-release-stable.js"
}, },
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
+1 -6
View File
@@ -389,9 +389,6 @@ export class Executor {
}; };
await instance.setCtx(taskCtx); await instance.setCtx(taskCtx);
if (!(instance instanceof AbstractTaskPlugin)) {
throw new Error(`插件类型错误:${step.type}不是AbstractTaskPlugin的实例`);
}
await instance.onInstance(); await instance.onInstance();
const result = await instance.execute(); const result = await instance.execute();
//执行结果处理 //执行结果处理
@@ -401,7 +398,6 @@ export class Executor {
} }
//输出上下文变量到output context //输出上下文变量到output context
forEach(define.output, (item: any, key: any) => { forEach(define.output, (item: any, key: any) => {
// @ts-ignore
step.status!.output[key] = instance[key]; step.status!.output[key] = instance[key];
// const stepOutputKey = `step.${step.id}.${key}`; // const stepOutputKey = `step.${step.id}.${key}`;
// this.runtime.context[stepOutputKey] = instance[key]; // this.runtime.context[stepOutputKey] = instance[key];
@@ -415,8 +411,7 @@ export class Executor {
merge(vars, instance._result.pipelineVars); merge(vars, instance._result.pipelineVars);
await this.pipelineContext.setObj("vars", vars); await this.pipelineContext.setObj("vars", vars);
} }
// @ts-ignore if (Object.keys(instance._result.pipelinePrivateVars).length > 0) {
if (Object.keys(instance._result?.pipelinePrivateVars).length > 0) {
// 判断 pipelineVars 有值时更新 // 判断 pipelineVars 有值时更新
let vars = await this.pipelineContext.getObj("privateVars"); let vars = await this.pipelineContext.getObj("privateVars");
vars = vars || {}; vars = vars || {};
@@ -568,18 +568,11 @@ export class RuntimeDepsService {
const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv }); const result = await this.commandRunner.run(command, args, { cwd: rootDir, timeoutMs: this.installTimeoutMs, env: tryEnv });
if (result.code === 0) { if (result.code === 0) {
this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl: tryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) }); this.writeInstallState(statePath, { installedAt: new Date().toISOString(), registryUrl: tryUrl, dependenciesHash, nodeVersion: process.version, pnpmVersion, lockFileExists: fs.existsSync(lockPath) });
log.info(`${result.stdout?.slice(-2000) || "无npm安装日志输出"}`);
log.info("第三方依赖安装完成"); log.info("第三方依赖安装完成");
return { registryUrl: tryUrl, packageJsonPath }; return { registryUrl: tryUrl, packageJsonPath };
} }
const errOutput = (result.stderr || "").trim(); lastError = result.stderr || result.stdout || "unknown error";
const outOutput = (result.stdout || "").trim(); log.warn?.(`镜像 ${tryUrl || "默认"} 安装失败${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
lastError = errOutput || outOutput || "unknown error";
log.info(`镜像 ${tryUrl || "默认"} 安装失败,退出码: ${result.code}${urlsToTry.length > 1 ? ",尝试下一个镜像..." : ""}`);
log.info(` pnpm stderr: ${(errOutput || "无npm安装日志输出").slice(-2000)}`);
if (outOutput) {
log.info(` pnpm stdout: ${outOutput.slice(-2000)}`);
}
} }
this.writeInstallState(statePath, { this.writeInstallState(statePath, {
...currentState, ...currentState,
@@ -1,4 +1,4 @@
import { nanoid } from "nanoid"; import { nanoid } from 'nanoid';
export type IframeMessageData<T> = { export type IframeMessageData<T> = {
action: string; action: string;
@@ -29,12 +29,10 @@ export class IframeClient {
onError?: any; onError?: any;
handlers: Record<string, (data: IframeMessageData<any>) => Promise<void>> = {}; handlers: Record<string, (data: IframeMessageData<any>) => Promise<void>> = {};
private messageHandler: (event: MessageEvent<IframeMessageData<any>>) => Promise<void>;
constructor(iframe?: HTMLIFrameElement, onError?: (e: any) => void) { constructor(iframe?: HTMLIFrameElement, onError?: (e: any) => void) {
this.iframe = iframe; this.iframe = iframe;
this.onError = onError; this.onError = onError;
this.messageHandler = async (event: MessageEvent<IframeMessageData<any>>) => { window.addEventListener('message', async (event: MessageEvent<IframeMessageData<any>>) => {
const data = event.data; const data = event.data;
if (data.action) { if (data.action) {
console.log(`收到消息[isSub:${this.isInFrame()}]`, data); console.log(`收到消息[isSub:${this.isInFrame()}]`, data);
@@ -42,21 +40,20 @@ export class IframeClient {
const handler = this.handlers[data.action]; const handler = this.handlers[data.action];
if (handler) { if (handler) {
const res = await handler(data); const res = await handler(data);
if (data.id && data.action !== "reply") { if (data.id && data.action !== 'reply') {
await this.send("reply", res, data.id); await this.send('reply', res, data.id);
} }
} else { } else {
throw new Error(`action:${data.action} 未注册处理器,可能版本过低`); throw new Error(`action:${data.action} 未注册处理器,可能版本过低`);
} }
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
await this.send("reply", {}, data.id, 500, e.message); await this.send('reply', {}, data.id, 500, e.message);
} }
} }
}; });
window.addEventListener("message", this.messageHandler);
this.register("reply", async data => { this.register('reply', async data => {
const req = this.requestQueue[data.replyId!]; const req = this.requestQueue[data.replyId!];
if (req) { if (req) {
req.onReply(data); req.onReply(data);
@@ -64,17 +61,11 @@ export class IframeClient {
} }
}); });
} }
isInFrame() {
public destroy() {
window.removeEventListener("message", this.messageHandler);
this.requestQueue = {};
this.handlers = {};
}
public isInFrame() {
return window.self !== window.top; return window.self !== window.top;
} }
public register<T = any>(action: string, handler: (data: IframeMessageData<T>) => Promise<any>) { register<T = any>(action: string, handler: (data: IframeMessageData<T>) => Promise<any>) {
this.handlers[action] = handler; this.handlers[action] = handler;
} }
@@ -115,12 +106,12 @@ export class IframeClient {
console.log(`send message[isSub:${this.isInFrame()}]:`, reqMessageData); console.log(`send message[isSub:${this.isInFrame()}]:`, reqMessageData);
if (!this.iframe) { if (!this.iframe) {
if (!window.parent) { if (!window.parent) {
reject("当前页面不在 iframe 中"); reject('当前页面不在 iframe 中');
} }
window.parent.postMessage(reqMessageData, "*"); window.parent.postMessage(reqMessageData, '*');
} else { } else {
//子页面 //子页面
this.iframe.contentWindow?.postMessage(reqMessageData, "*"); this.iframe.contentWindow?.postMessage(reqMessageData, '*');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -1,34 +0,0 @@
/// <reference types="mocha" />
import assert from "node:assert/strict";
import { AuditLogContext } from "./audit.js";
// AuditLog decorator and getAuditLogOptions are removed since auditLog()
// now signals audit intent directly via ctx.auditLog.enabled
describe("AuditLogContext type", () => {
it("supports enabled flag", () => {
const ctx: AuditLogContext = {
type: "pipeline",
action: "删除流水线",
append: ["ID:5"],
content: "删除了流水线(ID:5)",
projectId: 3,
enabled: true,
};
assert.equal(ctx.enabled, true);
assert.equal(ctx.type, "pipeline");
assert.equal(ctx.content, "删除了流水线(ID:5)");
assert.equal(ctx.projectId, 3);
});
it("works with minimal fields", () => {
const ctx: AuditLogContext = {
enabled: true,
append: ["提交2条"],
};
assert.equal(ctx.enabled, true);
});
});
@@ -1,44 +0,0 @@
export type AuditLogOptions = {
type?: string;
action?: string;
content?: string;
template?: string;
disabled?: boolean;
};
export type AuditLogContext = {
type?: string;
action?: string;
append?: string | string[];
content?: string;
projectId?: number;
enabled?: boolean;
scope?: string;
userId?: number;
username?: string;
success?: boolean;
};
/** 审计日志方法的参数类型 */
export type AuditLogParam = {
type?: string;
action?: string;
content?: string;
append?: string | string[];
projectId?: number;
userId?: number;
username?: string;
};
/** AuditService.log() 参数类型 */
export type AuditLogWriteParam = {
userId: number;
type: string;
action: string;
content: string;
username?: string;
projectId?: number;
ipAddress?: string;
scope?: string;
success?: boolean;
};
@@ -3,7 +3,6 @@ import type { IMidwayContainer } from "@midwayjs/core";
import * as koa from "@midwayjs/koa"; import * as koa from "@midwayjs/koa";
import { Constants } from "./constants.js"; import { Constants } from "./constants.js";
import { isEnterprise } from "./mode.js"; import { isEnterprise } from "./mode.js";
import type { AuditLogContext, AuditLogParam } from "./audit.js";
export abstract class BaseController { export abstract class BaseController {
@Inject() @Inject()
@@ -128,43 +127,4 @@ export abstract class BaseController {
} }
return { projectId, userId }; return { projectId, userId };
} }
getAuditType(): string {
return "unknown";
}
auditLog(bean: AuditLogParam = {}) {
const auditLog = this.ensureAuditLogContext();
auditLog.enabled = true;
if (bean.userId != null) {
auditLog.userId = bean.userId;
}
if (bean.username != null) {
auditLog.username = bean.username;
}
if (bean.type != null) {
auditLog.type = bean.type;
}
if (bean.action != null) {
auditLog.action = bean.action;
}
if (bean.projectId != null) {
auditLog.projectId = bean.projectId;
}
if (bean.content) {
auditLog.content = bean.content;
}
if (bean.append) {
const items = Array.isArray(bean.append) ? bean.append : [bean.append];
const old = Array.isArray(auditLog.append) ? auditLog.append : auditLog.append ? [auditLog.append] : [];
auditLog.append = [...old, ...items].filter(item => item && String(item).trim());
}
}
private ensureAuditLogContext(): AuditLogContext {
if (!this.ctx.auditLog) {
this.ctx.auditLog = {};
}
return this.ctx.auditLog;
}
} }
+35 -35
View File
@@ -1,21 +1,21 @@
export const Constants = { export const Constants = {
dataDir: "./data", dataDir: './data',
role: { role: {
defaultUser: 3, defaultUser: 3,
}, },
per: { per: {
//无需登录 //无需登录
guest: "_guest_", guest: '_guest_',
//无需登录 //无需登录
anonymous: "_guest_", anonymous: '_guest_',
//无需登录,有 token 时解析当前用户 //无需登录,有 token 时解析当前用户
guestOptionalAuth: "_guestOptionalAuth_", guestOptionalAuth: '_guestOptionalAuth_',
//仅需要登录 //仅需要登录
authOnly: "_authOnly_", authOnly: '_authOnly_',
//仅需要登录 //仅需要登录
loginOnly: "_authOnly_", loginOnly: '_authOnly_',
open: "_open_", open: '_open_',
}, },
res: { res: {
serverError(message: string) { serverError(message: string) {
@@ -26,102 +26,102 @@ export const Constants = {
}, },
error: { error: {
code: 1, code: 1,
message: "Internal server error", message: 'Internal server error',
}, },
success: { success: {
code: 0, code: 0,
message: "success", message: 'success',
}, },
validation: { validation: {
code: 10, code: 10,
message: "参数错误", message: '参数错误',
}, },
needvip: { needvip: {
code: 88, code: 88,
message: "需要VIP", message: '需要VIP',
}, },
needsuite: { needsuite: {
code: 89, code: 89,
message: "需要购买或升级套餐", message: '需要购买或升级套餐',
}, },
loginError: { loginError: {
code: 2, code: 2,
message: "登录失败", message: '登录失败',
}, },
codeError: { codeError: {
code: 3, code: 3,
message: "验证码错误", message: '验证码错误',
}, },
auth: { auth: {
code: 401, code: 401,
message: "您还未登录或token已过期", message: '您还未登录或token已过期',
}, },
permission: { permission: {
code: 402, code: 402,
message: "您没有权限", message: '您没有权限',
}, },
param: { param: {
code: 400, code: 400,
message: "参数错误", message: '参数错误',
}, },
notFound: { notFound: {
code: 404, code: 404,
message: "页面/文件/资源不存在", message: '页面/文件/资源不存在',
}, },
preview: { preview: {
code: 10001, code: 10001,
message: "对不起,预览环境不允许修改此数据", message: '对不起,预览环境不允许修改此数据',
}, },
siteOff: { siteOff:{
code: 10010, code: 10010,
message: "站点已关闭", message: '站点已关闭',
}, },
need2fa: { need2fa:{
code: 10020, code: 10020,
message: "需要2FA认证", message: '需要2FA认证',
}, },
openKeyError: { openKeyError: {
code: 20000, code: 20000,
message: "ApiToken错误", message: 'ApiToken错误',
}, },
openKeySignError: { openKeySignError: {
code: 20001, code: 20001,
message: "ApiToken签名错误", message: 'ApiToken签名错误',
}, },
openKeyExpiresError: { openKeyExpiresError: {
code: 20002, code: 20002,
message: "ApiToken时间戳错误", message: 'ApiToken时间戳错误',
}, },
openKeySignTypeError: { openKeySignTypeError: {
code: 20003, code: 20003,
message: "ApiToken签名类型不支持", message: 'ApiToken签名类型不支持',
}, },
openParamError: { openParamError: {
code: 20010, code: 20010,
message: "请求参数错误", message: '请求参数错误',
}, },
openCertNotFound: { openCertNotFound: {
code: 20011, code: 20011,
message: "证书不存在", message: '证书不存在',
}, },
openCertNotReady: { openCertNotReady: {
code: 20012, code: 20012,
message: "证书还未生成", message: '证书还未生成',
}, },
openCertApplying: { openCertApplying: {
code: 20013, code: 20013,
message: "证书正在申请中,请稍后重新获取", message: '证书正在申请中,请稍后重新获取',
}, },
openDomainNoVerifier: { openDomainNoVerifier:{
code: 20014, code: 20014,
message: "域名校验方式未配置", message: '域名校验方式未配置',
}, },
openEmailNotFound: { openEmailNotFound: {
code: 20021, code: 20021,
message: "用户邮箱还未配置", message: '用户邮箱还未配置',
}, },
}, },
systemUserId: 0, // 系统级别userid固定为0 systemUserId: 0, // 系统级别userid固定为0
enterpriseUserId: -1, // 企业模式用户id固定为-1 enterpriseUserId: -1 // 企业模式用户id固定为-1
}; };
@@ -1,20 +0,0 @@
import { createRequestParamDecorator } from "@midwayjs/core";
export const AuditLog = (opts: { type?: string; action?: string; content?: string; enabled?: boolean } = {}) => {
return createRequestParamDecorator(ctx => {
if (!ctx.auditLog) {
ctx.auditLog = {};
}
ctx.auditLog.enabled = opts.enabled !== false;
if (opts.type != null) {
ctx.auditLog.type = opts.type;
}
if (opts.action != null) {
ctx.auditLog.action = opts.action;
}
if (opts.content != null) {
ctx.auditLog.content = opts.content;
}
return ctx.auditLog;
});
};
@@ -1 +0,0 @@
export * from "./decoractor.js";
@@ -1,11 +1,11 @@
import { ALL, Body, Post, Query } from "@midwayjs/core"; import { ALL, Body, Post, Query } from '@midwayjs/core';
import { BaseController } from "./base-controller.js"; import { BaseController } from './base-controller.js';
export abstract class CrudController<T> extends BaseController { export abstract class CrudController<T> extends BaseController {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract getService<T>(); abstract getService<T>();
@Post("/page") @Post('/page')
async page(@Body(ALL) body: any) { async page(@Body(ALL) body: any) {
const pageRet = await this.getService().page({ const pageRet = await this.getService().page({
query: body.query ?? {}, query: body.query ?? {},
@@ -16,7 +16,7 @@ export abstract class CrudController<T> extends BaseController {
return this.ok(pageRet); return this.ok(pageRet);
} }
@Post("/list") @Post('/list')
async list(@Body(ALL) body: any) { async list(@Body(ALL) body: any) {
const listRet = await this.getService().list({ const listRet = await this.getService().list({
query: body.query ?? {}, query: body.query ?? {},
@@ -25,33 +25,33 @@ export abstract class CrudController<T> extends BaseController {
return this.ok(listRet); return this.ok(listRet);
} }
@Post("/add") @Post('/add')
async add(@Body(ALL) bean: any) { async add(@Body(ALL) bean: any) {
delete bean.id; delete bean.id;
const id = await this.getService().add(bean); const id = await this.getService().add(bean);
return this.ok(id); return this.ok(id);
} }
@Post("/info") @Post('/info')
async info(@Query("id") id: number) { async info(@Query('id') id: number) {
const bean = await this.getService().info(id); const bean = await this.getService().info(id);
return this.ok(bean); return this.ok(bean);
} }
@Post("/update") @Post('/update')
async update(@Body(ALL) bean: any) { async update(@Body(ALL) bean: any) {
await this.getService().update(bean); await this.getService().update(bean);
return this.ok(null); return this.ok(null);
} }
@Post("/delete") @Post('/delete')
async delete(@Query("id") id: number) { async delete(@Query('id') id: number) {
await this.getService().delete([id]); await this.getService().delete([id]);
return this.ok(null); return this.ok(null);
} }
@Post("/deleteByIds") @Post('/deleteByIds')
async deleteByIds(@Body("ids") ids: number[]) { async deleteByIds(@Body('ids') ids: number[]) {
await this.getService().delete(ids); await this.getService().delete(ids);
return this.ok(null); return this.ok(null);
} }
@@ -1,14 +1,12 @@
import { Constants } from "../constants.js"; import { Constants } from '../constants.js';
import { BaseException } from "./base-exception.js"; import { BaseException } from './base-exception.js';
/** /**
* 通用异常 * 通用异常
*/ */
export class LoginErrorException extends BaseException { export class LoginErrorException extends BaseException {
leftCount: number; leftCount: number;
userId?: number; constructor(message, leftCount: number) {
constructor(message, leftCount: number, userId?: number) { super('LoginErrorException', Constants.res.loginError.code, message ? message : Constants.res.loginError.message);
super("LoginErrorException", Constants.res.loginError.code, message ? message : Constants.res.loginError.message);
this.leftCount = leftCount; this.leftCount = leftCount;
this.userId = userId;
} }
} }
+8 -10
View File
@@ -1,10 +1,8 @@
export * from "./base-controller.js"; export * from './base-controller.js';
export * from "./constants.js"; export * from './constants.js';
export * from "./crud-controller.js"; export * from './crud-controller.js';
export * from "./enum-item.js"; export * from './enum-item.js';
export * from "./exception/index.js"; export * from './exception/index.js';
export * from "./result.js"; export * from './result.js';
export * from "./base-service.js"; export * from './base-service.js';
export * from "./audit.js"; export * from "./mode.js"
export * from "./mode.js";
export * from "./core/index.js";
+8 -8
View File
@@ -1,12 +1,12 @@
let adminMode = "saas"; let adminMode = "saas"
export function setAdminMode(mode: string = "saas") { export function setAdminMode(mode:string = "saas"){
adminMode = mode; adminMode = mode
} }
export function getAdminMode() { export function getAdminMode(){
return adminMode; return adminMode
} }
export function isEnterprise() { export function isEnterprise(){
return adminMode === "enterprise"; return adminMode === "enterprise"
} }
@@ -164,7 +164,7 @@ export class PlusService {
baseURL: plusRequestService.getBaseURL(), baseURL: plusRequestService.getBaseURL(),
method: "post", method: "post",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Berear ${token}`,
}, },
}); });
const res = await http.request(config); const res = await http.request(config);
@@ -173,9 +173,4 @@ export class PlusService {
} }
return res.data; return res.data;
} }
async request(config: HttpRequestConfig) {
const plusRequestService = await this.getPlusRequestService();
return await plusRequestService.request(config);
}
} }
@@ -279,11 +279,3 @@ export class SysSafeSetting extends BaseSettings {
autoHiddenTimes: 5, autoHiddenTimes: 5,
}; };
} }
export class SysPluginSetting extends BaseSettings {
static __title__ = "系统插件设置";
static __key__ = "sys.plugin";
static __access__ = "private";
lastSyncTime?: number;
}
+5 -8
View File
@@ -93,21 +93,19 @@ export class Flyway {
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
this.errorTip(err);
await this.storeSqlExecLog(file.script, filepath, false, queryRunner); await this.storeSqlExecLog(file.script, filepath, false, queryRunner);
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
if (err.code === "SQLITE_IOERR_WRITE") {
this.logger.warn("SQLite数据库写入失败,可能您的操作系统版本太低,请将「certd:latest」镜像改为「certd:slim」即可。(如需指定版本可以修改成「certd:[version]-slim」)", file.script);
}
throw err; throw err;
} }
} }
this.logger.info("[ midfly ] end-------------"); this.logger.info("[ midfly ] end-------------");
} }
private errorTip(err: any) {
if (err.code === "SQLITE_IOERR_WRITE") {
this.logger.warn("SQLite数据库写入失败,可能您的操作系统版本太低,请将「certd:latest」镜像改为「certd:slim」即可。(如需指定版本可以修改成「certd:[version]-slim」)");
}
}
private async storeSqlExecLog(filename: string, filepath: string, success: boolean, queryRunner: QueryRunner) { private async storeSqlExecLog(filename: string, filepath: string, success: boolean, queryRunner: QueryRunner) {
const hash = await this.getFileHash(filepath); const hash = await this.getFileHash(filepath);
//先删除 //先删除
@@ -267,7 +265,6 @@ export class Flyway {
await queryRunner.query(sql); await queryRunner.query(sql);
} catch (err: any) { } catch (err: any) {
this.logger.error("exec sql error ", err.message, err); this.logger.error("exec sql error ", err.message, err);
this.errorTip(err);
throw err; throw err;
} }
} }
+1 -2
View File
@@ -1,4 +1,4 @@
ARG base_type=alpine ARG base_type=alpine
# 根据 base_type 参数选择基础镜像系列 # 根据 base_type 参数选择基础镜像系列
FROM --platform=linux/amd64 node:22-alpine AS base-amd64-alpine FROM --platform=linux/amd64 node:22-alpine AS base-amd64-alpine
@@ -99,7 +99,6 @@ RUN ARCH=$(uname -m) && \
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
ENV NODE_ENV=production ENV NODE_ENV=production
ENV certd_release_mode=latest
ENV MIDWAY_SERVER_ENV=production ENV MIDWAY_SERVER_ENV=production
RUN npm install -g pnpm@10.33.4 RUN npm install -g pnpm@10.33.4
@@ -139,7 +139,7 @@ async function doActive() {
title: t("vip.successTitle"), title: t("vip.successTitle"),
content: t("vip.successContent", { content: t("vip.successContent", {
vipLabel, vipLabel,
expireDate: settingStore.plusInfo.expireTime === -1 ? t("vip.permanent") : dayjs(settingStore.plusInfo.expireTime).format("YYYY-MM-DD"), expireDate: dayjs(settingStore.plusInfo.expireTime).format("YYYY-MM-DD"),
}), }),
onOk() { onOk() {
if (!(settingStore.installInfo.bindUserId > 0)) { if (!(settingStore.installInfo.bindUserId > 0)) {
@@ -108,10 +108,10 @@ const projectStore = useProjectStore();
<div v-if="!settingStore.isComm" class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full hidden md:block"> <div v-if="!settingStore.isComm" class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full hidden md:block">
<fs-button shape="circle" type="text" icon="ion:logo-github" :text="null" @click="goGithub" /> <fs-button shape="circle" type="text" icon="ion:logo-github" :text="null" @click="goGithub" />
</div> </div>
<MaxKBChat v-if="settingsStore.sysPublic.aiChatEnabled !== false" ref="chatBox" />
</template> </template>
<template #footer> <template #footer>
<PageFooter></PageFooter> <PageFooter></PageFooter>
<MaxKBChat v-if="settingsStore.sysPublic.aiChatEnabled !== false" ref="chatBox" />
</template> </template>
</BasicLayout> </BasicLayout>
</template> </template>
@@ -2,8 +2,9 @@
<router-view> </router-view> <router-view> </router-view>
</template> </template>
<script lang="ts" setup> <!--<script lang="ts" setup>-->
import { useSettingStore } from "../store/settings"; <!--import { usePageStore } from "/@/store/modules/page";-->
const settingsStore = useSettingStore(); <!--const pageStore = usePageStore();-->
</script> <!--const keepAlive = pageStore.keepAlive;-->
<!--</script>-->
@@ -46,7 +46,6 @@ export default {
default: "Default", default: "Default",
success: "Success", success: "Success",
test: "Test", test: "Test",
operation: "Operation",
testButton: "Test", testButton: "Test",
operationSuccess: "Operation successful", operationSuccess: "Operation successful",
batch_delete: "Batch Delete", batch_delete: "Batch Delete",
@@ -25,7 +25,5 @@ export default {
expiringCerts: "Soon-to-Expire Certificates", expiringCerts: "Soon-to-Expire Certificates",
supportedTasks: "Overview of Supported Deployment Tasks", supportedTasks: "Overview of Supported Deployment Tasks",
changeLog: "Change Log", changeLog: "Change Log",
stableRelease: "Stable",
latestRelease: "Preview",
}, },
}; };
@@ -18,7 +18,6 @@ export default {
openKey: "Open API Key", openKey: "Open API Key",
notification: "Notification Settings", notification: "Notification Settings",
siteMonitorSetting: "Site Monitor Settings", siteMonitorSetting: "Site Monitor Settings",
auditLog: "Audit Log",
userSecurity: "Security Settings", userSecurity: "Security Settings",
userProfile: "Account Info", userProfile: "Account Info",
userGrant: "Grant Delegation", userGrant: "Grant Delegation",
@@ -40,6 +39,7 @@ export default {
headerMenus: "Top Menu Settings", headerMenus: "Top Menu Settings",
sysAccess: "System-level Authorization", sysAccess: "System-level Authorization",
sysPlugin: "Plugin Management", sysPlugin: "Plugin Management",
sysPluginEdit: "Edit Plugin",
sysPluginConfig: "Certificate Plugin Configuration", sysPluginConfig: "Certificate Plugin Configuration",
accountBind: "Account Binding", accountBind: "Account Binding",
permissionManager: "Permission Management", permissionManager: "Permission Management",
@@ -67,7 +67,6 @@ export default {
projectJoin: "Join Project", projectJoin: "Join Project",
currentProject: "Current Project", currentProject: "Current Project",
projectMemberManager: "Project Member", projectMemberManager: "Project Member",
auditLog: "Audit Log",
domainMonitorSetting: "Domain Monitor Settings", domainMonitorSetting: "Domain Monitor Settings",
}, },
}; };
@@ -42,72 +42,6 @@ export default {
pluginGroup: "Plugin Group", pluginGroup: "Plugin Group",
pluginManagement: "Plugin Management", pluginManagement: "Plugin Management",
pluginBetaWarning: "Custom plugins are in BETA and may have breaking changes in future", pluginBetaWarning: "Custom plugins are in BETA and may have breaking changes in future",
localPlugin: "Installed",
pluginMarket: "Plugin Market",
installedStorePlugin: "Installed",
onlinePluginManagement: "Online Plugins",
onlinePluginSearch: "Search online plugins",
onlinePluginSync: "Sync Plugin Market",
onlinePluginSyncFirst: "Sync the plugin market list first, then search and install plugins",
onlinePluginSyncSuccess: "Plugin market list synced successfully",
onlinePluginLastSyncTime: "Last synced: {time}",
onlinePluginNotSynced: "Plugin market has not been synced",
onlinePluginRefresh: "Refresh List",
onlinePluginInstall: "Install",
onlinePluginUpdate: "Update",
onlinePluginReinstall: "Reinstall",
onlinePluginUninstall: "Uninstall",
onlinePluginStatus: "Status",
onlinePluginInstalled: "Installed",
onlinePluginAiReviewPassed: "AI reviewed",
onlinePluginEnabled: "Enabled",
onlinePluginDisabled: "Disabled",
onlinePluginClickToEnable: "Click to enable",
onlinePluginClickToDisable: "Click to disable",
onlinePluginDownloadCount: "Downloads: {count}",
onlinePluginNotInstalled: "Not Installed",
onlinePluginUpgradeAvailable: "Upgradeable",
onlinePluginCurrentVersion: "Current {version}",
onlinePluginAlreadyLatest: "Already up to date",
onlinePluginClickToUpdate: "Click to update",
onlinePluginSelfAuthored: "Mine",
onlinePluginInstallSuccess: "Online plugin installed successfully",
onlinePluginUninstallSuccess: "Online plugin uninstalled successfully",
onlinePluginDeleteConfirm: 'Are you sure you want to uninstall online plugin "{name}"? Pipelines using this plugin may fail after deletion.',
onlinePluginPublish: "Publish to Plugin Market",
onlinePluginPublishManage: "Plugin Publish Management",
onlinePluginPublishConfirm: 'Publish "{name}" to the plugin market? It will be checked by AI and then wait for administrator review.',
onlinePluginPublishConfirmTip: "Please confirm the plugin information. It will be checked by AI and then wait for administrator review.",
onlinePluginPublishManageTip: "Confirm the current plugin information. If it has been submitted before, versions, publish status, and rejection reasons are shown here.",
onlinePluginPublishSubmit: "Submit Publish",
onlinePluginPublishSuccess: "Submitted, waiting for AI security review",
onlinePluginPublishVersionRequired: "Please set the plugin version before publishing",
onlinePluginPublishStatus: "Publish Status",
onlinePluginNotSubmitted: "Not Submitted",
onlinePluginVersionManagement: "Versions",
onlinePluginLatestVersion: "Latest Version",
onlinePluginCurrentRelease: "This Release",
onlinePluginPublishPrompt: "Release Notes",
onlinePluginVersionRejectedReason: "Rejection Reason",
onlinePluginNoSubmittedVersion: "No release has been submitted yet",
onlinePluginVersionFormatError: "Use a dotted numeric version, for example 1.0.0",
onlinePluginVersionBaselineError: "Enter a version greater than v{version}",
onlinePluginStatusDraft: "Draft",
onlinePluginStatusReviewing: "Reviewing",
onlinePluginStatusPublished: "Published",
onlinePluginStatusRejected: "Rejected",
onlinePluginStatusOffline: "Offline",
onlinePluginReviewAiPending: "Pending AI Review",
onlinePluginReviewPending: "Pending Manual Review",
onlinePluginReviewPassed: "Passed",
onlinePluginReviewRejected: "Rejected",
onlinePluginAuthorRegister: "Register Plugin Author",
onlinePluginAuthorName: "Author Name",
onlinePluginAuthorDisplayName: "Display Name",
onlinePluginAuthorNotRegistered: "Not Registered",
onlinePluginAuthorNameRequired: "Please enter an author name",
onlinePluginAuthorNameHelper: "Used as the plugin market author prefix. Each bound user can register only one author.",
onlinePluginAuthorNameRuleMsg: "Must start with a letter and contain only letters, digits, hyphens, or underscores",
pleaseSelectRecord: "Please select records first", pleaseSelectRecord: "Please select records first",
clearRuntimeDeps: "Clear Runtime Deps Cache", clearRuntimeDeps: "Clear Runtime Deps Cache",
clearRuntimeDepsTooltip: "Restart the certd container after clearing, otherwise cached modules will not be reloaded", clearRuntimeDepsTooltip: "Restart the certd container after clearing, otherwise cached modules will not be reloaded",
@@ -104,5 +104,4 @@ export default {
not_effective: "Not effective or duration not sync?", not_effective: "Not effective or duration not sync?",
learn_more: "More privileges", learn_more: "More privileges",
question: "More VIP related questions", question: "More VIP related questions",
permanent: "Permanent",
}; };
@@ -50,7 +50,6 @@ export default {
default: "默认", default: "默认",
success: "成功", success: "成功",
test: "测试", test: "测试",
operation: "操作",
testButton: "测试", testButton: "测试",
operationSuccess: "操作成功", operationSuccess: "操作成功",
batch_delete: "批量删除", batch_delete: "批量删除",
@@ -26,7 +26,5 @@ export default {
expiringCerts: "最快到期证书", expiringCerts: "最快到期证书",
supportedTasks: "已支持的部署任务总览", supportedTasks: "已支持的部署任务总览",
changeLog: "更新日志", changeLog: "更新日志",
stableRelease: "稳定版",
latestRelease: "预览版",
}, },
}; };
@@ -18,7 +18,6 @@ export default {
openKey: "开放接口密钥", openKey: "开放接口密钥",
notification: "通知设置", notification: "通知设置",
siteMonitorSetting: "站点监控设置", siteMonitorSetting: "站点监控设置",
auditLog: "操作日志",
userSecurity: "认证安全设置", userSecurity: "认证安全设置",
userProfile: "账号信息", userProfile: "账号信息",
userGrant: "授权委托", userGrant: "授权委托",
@@ -41,6 +40,7 @@ export default {
headerMenus: "顶部菜单设置", headerMenus: "顶部菜单设置",
sysAccess: "系统级授权", sysAccess: "系统级授权",
sysPlugin: "插件管理", sysPlugin: "插件管理",
sysPluginEdit: "编辑插件",
sysPluginConfig: "证书插件配置", sysPluginConfig: "证书插件配置",
accountBind: "账号绑定", accountBind: "账号绑定",
permissionManager: "权限管理", permissionManager: "权限管理",
@@ -67,7 +67,6 @@ export default {
projectJoin: "加入项目", projectJoin: "加入项目",
currentProject: "当前项目", currentProject: "当前项目",
projectMemberManager: "项目成员管理", projectMemberManager: "项目成员管理",
auditLog: "审计日志",
domainMonitorSetting: "域名监控设置", domainMonitorSetting: "域名监控设置",
jobHistory: "监控执行记录", jobHistory: "监控执行记录",
}, },
@@ -41,72 +41,6 @@ export default {
pluginGroup: "插件分组", pluginGroup: "插件分组",
pluginManagement: "插件管理", pluginManagement: "插件管理",
pluginBetaWarning: "自定义插件处于BETA测试版,后续可能会有破坏性变更", pluginBetaWarning: "自定义插件处于BETA测试版,后续可能会有破坏性变更",
localPlugin: "本地插件",
pluginMarket: "插件市场",
installedStorePlugin: "插件市场",
onlinePluginManagement: "在线插件",
onlinePluginSearch: "搜索在线插件",
onlinePluginSync: "同步插件市场",
onlinePluginSyncFirst: "请先同步插件市场列表,然后再搜索和安装插件",
onlinePluginSyncSuccess: "插件市场列表同步成功",
onlinePluginLastSyncTime: "上次同步时间:{time}",
onlinePluginNotSynced: "尚未同步插件市场",
onlinePluginRefresh: "刷新列表",
onlinePluginInstall: "安装",
onlinePluginUpdate: "更新",
onlinePluginReinstall: "重新安装",
onlinePluginUninstall: "卸载",
onlinePluginStatus: "状态",
onlinePluginInstalled: "已安装",
onlinePluginAiReviewPassed: "AI审核通过",
onlinePluginEnabled: "已启用",
onlinePluginDisabled: "已禁用",
onlinePluginClickToEnable: "点击启用",
onlinePluginClickToDisable: "点击禁用",
onlinePluginDownloadCount: "下载量:{count}",
onlinePluginNotInstalled: "未安装",
onlinePluginUpgradeAvailable: "可更新",
onlinePluginCurrentVersion: "当前 {version}",
onlinePluginAlreadyLatest: "当前已是最新版本",
onlinePluginClickToUpdate: "点击更新",
onlinePluginSelfAuthored: "我的",
onlinePluginInstallSuccess: "在线插件安装成功",
onlinePluginUninstallSuccess: "在线插件卸载成功",
onlinePluginDeleteConfirm: "确定要卸载在线插件「{name}」吗?如果该插件已被流水线使用,删除可能会导致执行失败。",
onlinePluginPublish: "发布到插件市场",
onlinePluginPublishManage: "插件发布管理",
onlinePluginPublishConfirm: "确定要将「{name}」发布到插件市场吗?提交后会进行 AI 安全审查,通过后等待管理员审核。",
onlinePluginPublishConfirmTip: "请确认以下插件信息,提交后会进行 AI 安全审查,通过后等待管理员审核。",
onlinePluginPublishManageTip: "请确认当前插件信息;如已提交过发布,可以在这里查看版本、发布状态和拒绝原因。",
onlinePluginPublishSubmit: "提交发布",
onlinePluginPublishSuccess: "已提交,等待 AI 安全审查",
onlinePluginPublishVersionRequired: "请先设置插件版本号后再发布",
onlinePluginPublishStatus: "发布状态",
onlinePluginNotSubmitted: "未提交",
onlinePluginVersionManagement: "版本管理",
onlinePluginLatestVersion: "最新版本",
onlinePluginCurrentRelease: "本次发布",
onlinePluginPublishPrompt: "发布提示",
onlinePluginVersionRejectedReason: "拒绝原因",
onlinePluginNoSubmittedVersion: "尚未提交过发布版本",
onlinePluginVersionFormatError: "版本号只能使用数字点分格式,例如 1.0.0",
onlinePluginVersionBaselineError: "请输入大于 v{version} 的版本号",
onlinePluginStatusDraft: "草稿",
onlinePluginStatusReviewing: "审核中",
onlinePluginStatusPublished: "已发布",
onlinePluginStatusRejected: "已拒绝",
onlinePluginStatusOffline: "已下架",
onlinePluginReviewAiPending: "待 AI 审查",
onlinePluginReviewPending: "待人工审核",
onlinePluginReviewPassed: "已通过",
onlinePluginReviewRejected: "已拒绝",
onlinePluginAuthorRegister: "注册插件作者",
onlinePluginAuthorName: "作者名称",
onlinePluginAuthorDisplayName: "显示名称",
onlinePluginAuthorNotRegistered: "未注册",
onlinePluginAuthorNameRequired: "请输入作者名称",
onlinePluginAuthorNameHelper: "作为插件市场作者前缀,同一个绑定用户只能注册一个作者",
onlinePluginAuthorNameRuleMsg: "必须以英文字母开头,只能包含英文字母、数字、中划线或下划线",
pleaseSelectRecord: "请先勾选记录", pleaseSelectRecord: "请先勾选记录",
clearRuntimeDeps: "清理第三方依赖缓存", clearRuntimeDeps: "清理第三方依赖缓存",
clearRuntimeDepsTooltip: "清除后需重启 certd 容器,否则已缓存模块不会重新读取", clearRuntimeDepsTooltip: "清除后需重启 certd 容器,否则已缓存模块不会重新读取",
@@ -103,5 +103,4 @@ export default {
not_effective: "VIP没有生效/时长未同步?", not_effective: "VIP没有生效/时长未同步?",
learn_more: "更多特权(加VIP群等)", learn_more: "更多特权(加VIP群等)",
question: "更多VIP相关问题", question: "更多VIP相关问题",
permanent: "永久",
}; };
@@ -287,22 +287,6 @@ export const certdResources = [
isMenu: true, isMenu: true,
}, },
}, },
{
title: "certd.auditLog",
name: "AuditLog",
path: "/certd/audit",
component: "/certd/audit/index.vue",
meta: {
icon: "ion:document-text-outline",
auth: true,
keepAlive: true,
isMenu: true,
show: () => {
const settingStore = useSettingStore();
return settingStore.isPlus;
},
},
},
{ {
title: "certd.userSecurity", title: "certd.userSecurity",
name: "UserSecurity", name: "UserSecurity",
@@ -154,6 +154,19 @@ export const sysResources = [
auth: true, auth: true,
}, },
}, },
{
title: "certd.sysResources.sysPluginEdit",
name: "SysPluginEdit",
path: "/sys/plugin/edit",
component: "/sys/plugin/edit.vue",
meta: {
isMenu: false,
icon: "ion:extension-puzzle",
permission: "sys:settings:view",
keepAlive: true,
auth: true,
},
},
{ {
title: "certd.sysResources.sysPluginConfig", title: "certd.sysResources.sysPluginConfig",
name: "SysPluginConfig", name: "SysPluginConfig",
@@ -397,22 +410,6 @@ export const sysResources = [
}, },
], ],
}, },
{
title: "certd.sysResources.auditLog",
name: "SysAuditLog",
path: "/sys/enterprise/audit",
component: "/sys/enterprise/audit/index.vue",
meta: {
icon: "ion:document-text-outline",
keepAlive: true,
auth: true,
isMenu: true,
show: () => {
const settingStore = useSettingStore();
return settingStore.isPlus;
},
},
},
{ {
title: "certd.sysResources.netTest", title: "certd.sysResources.netTest",
name: "NetTest", name: "NetTest",
@@ -153,22 +153,6 @@ export class PluginGroups {
} }
} }
function filterInstalledPluginGroups(groups: { [key: string]: PluginGroup }) {
const filteredGroups: { [key: string]: PluginGroup } = {};
for (const [key, group] of Object.entries(groups)) {
filteredGroups[key] = {
...group,
plugins: group.plugins.filter(plugin => {
if (plugin.type !== "store") {
return true;
}
return plugin.installed === true;
}),
};
}
return filteredGroups;
}
export const usePluginStore = defineStore({ export const usePluginStore = defineStore({
id: "app.plugin", id: "app.plugin",
state: (): PluginState => ({ state: (): PluginState => ({
@@ -178,9 +162,8 @@ export const usePluginStore = defineStore({
actions: { actions: {
async reload() { async reload() {
const groups = await api.GetGroups({}); const groups = await api.GetGroups({});
const installedGroups = filterInstalledPluginGroups(groups); this.group = new PluginGroups(groups, { mergeSetting: true });
this.group = new PluginGroups(installedGroups, { mergeSetting: true }); this.originGroup = new PluginGroups(cloneDeep(groups));
this.originGroup = new PluginGroups(cloneDeep(installedGroups));
console.log("group", this.group); console.log("group", this.group);
console.log("originGroup", this.originGroup); console.log("originGroup", this.originGroup);
}, },
@@ -11,7 +11,6 @@ export type AppInfo = {
version?: string; version?: string;
time?: number; time?: number;
deltaTime?: number; deltaTime?: number;
releaseMode?: string;
}; };
export type SiteInfo = { export type SiteInfo = {
title?: string; title?: string;
@@ -35,7 +35,6 @@ export interface SettingState {
version?: string; version?: string;
time?: number; time?: number;
deltaTime?: number; deltaTime?: number;
releaseMode?: string;
}; };
productInfo: { productInfo: {
notice?: string; notice?: string;
@@ -110,7 +109,6 @@ export const useSettingStore = defineStore({
version: "", version: "",
time: 0, time: 0,
deltaTime: 0, deltaTime: 0,
releaseMode: "latest",
}, },
productInfo: { productInfo: {
notice: "", notice: "",
@@ -231,9 +229,6 @@ export const useSettingStore = defineStore({
this.app.time = appInfo.time; this.app.time = appInfo.time;
this.app.version = appInfo.version; this.app.version = appInfo.version;
this.app.deltaTime = new Date().getTime() - this.app.time; this.app.deltaTime = new Date().getTime() - this.app.time;
if (appInfo.releaseMode) {
this.app.releaseMode = appInfo.releaseMode;
}
}, },
initSiteInfo(siteInfo: SiteInfo) { initSiteInfo(siteInfo: SiteInfo) {
//@ts-ignore //@ts-ignore
@@ -75,9 +75,3 @@ footer {
.ant-input-number { .ant-input-number {
min-width: 150px; min-width: 150px;
} }
.ant-dropdown-menu-title-content{
display: flex;
align-items: center;
gap: 6px;
}
@@ -36,116 +36,6 @@
align-items: center; align-items: center;
} }
.cd-card-section {
padding: 14px;
border: 1px solid #e8edf3;
border-radius: 10px;
background: #ffffff;
}
.cd-card-section-title {
margin-bottom: 12px;
color: #1f2937;
font-weight: 700;
&.cd-card-section-title--compact {
margin-bottom: 10px;
}
}
.cd-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
.cd-meta-item {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.cd-meta-label {
color: #7b8794;
}
.cd-meta-value {
min-width: 0;
color: #1f2937;
font-weight: 500;
word-break: break-all;
}
.cd-tip-box {
padding: 10px 12px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #f6f8fb;
color: #526172;
line-height: 22px;
&.cd-tip-box-warning {
border-color: #ffd591;
background: #fff7e6;
color: #ad6800;
}
&.cd-tip-box-danger {
border-color: #ffccc7;
background: #fff2f0;
color: #cf1322;
line-height: 21px;
}
}
.cd-tip-title {
margin-bottom: 3px;
font-weight: 600;
}
.cd-form-row {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: start;
gap: 10px;
}
.cd-form-label {
padding-top: 5px;
color: #5f6b7a;
}
.cd-text-input {
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
background: #ffffff;
color: #1f2937;
&.cd-text-input-error {
border-color: #ff4d4f;
}
}
.cd-field-error {
margin-top: 6px;
color: #cf1322;
font-size: 12px;
}
.cd-text-danger {
color: #cf1322;
}
.cd-text-muted {
color: #8c8c8c;
}
.ant-drawer-content { .ant-drawer-content {
&.fullscreen { &.fullscreen {
position: fixed; position: fixed;
@@ -250,9 +140,3 @@ button.ant-btn.ant-btn-default.isPlus {
} }
} }
} }
.fs-form-none-content {
.fs-form-wrapper{
.fs-form-content{display: none;}
}
}
@@ -9,14 +9,12 @@ export type FormOptionReq = {
initialForm?: any; initialForm?: any;
zIndex?: number; zIndex?: number;
wrapper?: any; wrapper?: any;
noneForm?: boolean; //是否隐藏表单,只显示注入的body
}; };
export function useFormDialog() { export function useFormDialog() {
const { openCrudFormDialog } = useFormWrapper(); const { openCrudFormDialog } = useFormWrapper();
async function openFormDialog(req: FormOptionReq) { async function openFormDialog(req: FormOptionReq) {
const noneForm = req.noneForm ?? Object.keys(req.columns).length === 0;
function createCrudOptions() { function createCrudOptions() {
const warpper = merge( const warpper = merge(
{ {
@@ -26,7 +24,6 @@ export function useFormDialog() {
slots: { slots: {
"form-body-top": req.body, "form-body-top": req.body,
}, },
wrapClassName: noneForm ? "fs-form-none-content" : "",
}, },
req.wrapper req.wrapper
); );
@@ -1,28 +0,0 @@
import { request } from "/src/api/service";
const apiPrefix = "/pi/audit";
export function createAuditApi() {
return {
async GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query,
});
},
async DelObj(id: number) {
return await request({
url: apiPrefix + "/delete",
method: "post",
params: { id },
});
},
async GetDict() {
return await request({
url: apiPrefix + "/dict",
method: "post",
});
},
};
}
@@ -1,132 +0,0 @@
import { ColumnProps, DataFormatterContext, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, UserPageQuery, UserPageRes, dict } from "@fast-crud/fast-crud";
import { useI18n } from "/src/locales";
import { useDicts } from "../dicts";
const typeDict = dict({
url: "/pi/audit/dict",
getData: async () => {
const { createAuditApi } = await import("./api");
const api = createAuditApi();
const res = await api.GetDict();
return res.types || [];
},
});
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { t } = useI18n();
const { myProjectDict } = useDicts();
const api = context.api;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
const delRequest = async (req: DelReq) => {
return await api.DelObj(req.row.id);
};
return {
crudOptions: {
request: { pageRequest, delRequest },
toolbar: {
buttons: {
export: { show: true },
},
export: {
dataFrom: "search",
columnFilter: (col: ColumnProps) => col.show === true,
dataFormatter: (opts: DataFormatterContext) => {
const { row, originalRow, col } = opts;
const key = col.key;
if (key === "createTime" && originalRow[key]) {
row[key] = new Date(originalRow[key]).toLocaleString();
}
},
},
},
actionbar: {
buttons: {
add: { show: false },
},
},
rowHandle: {
width: 120,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
remove: { show: true },
copy: { show: false },
},
},
columns: {
id: {
title: "ID",
type: "number",
column: { width: 80 },
form: { show: false },
},
createTime: {
title: "操作时间",
type: "datetime",
search: {
show: true,
component: {
name: "a-range-picker",
},
},
column: { width: 170, sorter: true },
form: { show: false },
},
type: {
title: "操作类型",
type: "dict-select",
dict: typeDict,
search: { show: true },
column: { width: 120 },
form: { show: false },
},
action: {
title: "操作动作",
type: "text",
search: { show: true },
column: { width: 200, tooltip: true },
form: { show: false },
},
success: {
title: "结果",
type: "dict-switch",
dict: dict({
data: [
{ value: true, label: "成功", color: "success" },
{ value: false, label: "失败", color: "error" },
],
}),
column: { width: 100, align: "center" },
form: { show: false },
search: { show: true },
},
content: {
title: "备注",
type: "text",
search: { show: true },
column: { width: 700, tooltip: true },
form: { show: false },
},
ipAddress: {
title: "IP地址",
type: "text",
column: { width: 140 },
form: { show: false },
},
projectId: {
title: t("certd.fields.projectName"),
type: "dict-select",
dict: myProjectDict,
form: {
show: false,
},
},
},
},
};
}
@@ -1,24 +0,0 @@
<template>
<fs-page>
<template #header>
<div class="title">
操作日志
<span class="sub">查看您的操作记录</span>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { createAuditApi } from "./api";
import { useMounted } from "/@/use/use-mounted";
defineOptions({ name: "AuditLog" });
const api = createAuditApi();
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: { api } });
useMounted(() => crudExpose.doRefresh());
</script>
@@ -1,5 +1,5 @@
<template> <template>
<a-drawer v-model:open="stepDrawerVisible" placement="right" :closable="true" width="760px" class="step-form-drawer" :class="{ fullscreen }"> <a-drawer v-model:open="stepDrawerVisible" :wrap-style="{ maxWidth: '100vw' }" placement="right" :closable="true" width="760px" class="step-form-drawer" :class="{ fullscreen }">
<template #title> <template #title>
<div> <div>
编辑步骤 编辑步骤
@@ -12,25 +12,63 @@
</template> </template>
</div> </div>
<div class="hidden md:block"> <div class="hidden md:block">
<a-space> <fs-icon class="icon-button" :icon="fullscreen ? 'material-symbols:fullscreen-exit' : 'material-symbols:fullscreen'" @click="fullscreen = !fullscreen"></fs-icon>
<fs-icon class="icon-button" :icon="fullscreen ? 'material-symbols:fullscreen-exit' : 'material-symbols:fullscreen'" @click="fullscreen = !fullscreen"></fs-icon>
</a-space>
</div> </div>
</template> </template>
<template v-if="currentStep"> <template v-if="currentStep">
<pi-container v-if="currentStep._isAdd" class="pi-step-form"> <pi-container v-if="currentStep._isAdd" class="pi-step-form">
<template #header>
<a-row :gutter="10" class="mb-10">
<a-col :span="24" style="padding-left: 20px">
<a-input-search v-model:value="pluginSearch.keyword" placeholder="搜索插件" :allow-clear="true" :show-search="true"></a-input-search>
</a-col>
</a-row>
</template>
<div class="flex-col h-100 overflow-hidden md:ml-5 md:mr-5 step-form-body"> <div class="flex-col h-100 overflow-hidden md:ml-5 md:mr-5 step-form-body">
<a-tabs v-model:active-key="pluginSourceActive" class="step-plugin-source-tabs flex-1 overflow-hidden h-full"> <a-tabs v-model:active-key="pluginGroupActive" tab-position="left" class="flex-1 overflow-hidden h-full">
<a-tab-pane key="local" tab="已安装插件" class="h-full"> <template v-for="group of computedPluginGroups" :key="group.key">
<LocalPluginSelector :selected-type="currentStep.type" @select="stepTypeSelected" @confirm="handlePluginConfirm" /> <a-tab-pane v-if="(group.key === 'admin' && userStore.isAdmin) || group.key !== 'admin'" :key="group.key" class="scroll-y">
</a-tab-pane> <template #tab>
<a-tab-pane v-if="userStore.isAdmin" key="market" tab="插件市场" class="h-full step-market-pane"> <div class="cd-step-form-tab-label">
<OnlinePluginSelector :selected-type="currentStep.type" @select="stepTypeSelected" @confirm="handlePluginConfirm" @uninstalled="handleOnlinePluginUninstalled" /> <fs-icon :icon="group.icon" class="mr-2" />
</a-tab-pane> <div>{{ group.title }}</div>
</div>
</template>
<a-row v-if="!group.plugins || group.plugins.length === 0" :gutter="10">
<a-col class="flex-o">
<div class="flex-o m-10">没有找到插件</div>
</a-col>
</a-row>
<a-row v-else :gutter="10">
<a-col v-for="item of group.plugins" :key="item.key" class="step-plugin w-full md:w-[50%]">
<a-card
hoverable
:class="{ current: item.name === currentStep.type }"
@click="stepTypeSelected(item)"
@dblclick="
stepTypeSelected(item);
stepTypeSave();
"
>
<a-card-meta>
<template #title>
<fs-icon class="plugin-icon" :icon="item.icon || 'clarity:plugin-line'"></fs-icon>
<span class="title" :title="item.title">{{ item.title }}</span>
<vip-button v-if="item.needPlus" mode="icon" />
</template>
<template #description>
<span :title="item.desc" v-html="transformDesc(item.desc)"></span>
</template>
</a-card-meta>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</template>
</a-tabs> </a-tabs>
</div> </div>
<template #footer> <template #footer>
<div class="bottom-button"> <div style="padding: 20px; margin-left: 100px">
<a-button v-if="editMode" type="primary" @click="stepTypeSave"> 确定</a-button> <a-button v-if="editMode" type="primary" @click="stepTypeSave"> 确定</a-button>
</div> </div>
</template> </template>
@@ -79,30 +117,28 @@
<script lang="tsx" setup> <script lang="tsx" setup>
import { message, Modal } from "ant-design-vue"; import { message, Modal } from "ant-design-vue";
import { provide, ref, Ref } from "vue"; import { computed, provide, ref, Ref, watch } from "vue";
import { merge, cloneDeep } from "lodash-es"; import { merge, cloneDeep } from "lodash-es";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { usePluginStore } from "/@/store/plugin"; import { usePluginStore, PluginGroups } from "/@/store/plugin";
import { useCompute } from "@fast-crud/fast-crud"; import { useCompute } from "@fast-crud/fast-crud";
import { useReference } from "/@/use/use-refrence"; import { useReference } from "/@/use/use-refrence";
import { useSettingStore } from "/@/store/settings"; import { useSettingStore } from "/@/store/settings";
import { mitter } from "/@/utils/util.mitt"; import { mitter } from "/@/utils/util.mitt";
import { utils } from "/@/utils"; import { utils } from "/@/utils";
import { useUserStore } from "/@/store/user"; import { useUserStore } from "/@/store/user";
import LocalPluginSelector from "./local-plugin-selector.vue";
import OnlinePluginSelector from "./online-plugin-selector.vue";
defineOptions({ defineOptions({
name: "PiStepForm", name: "PiStepForm",
}); });
defineProps({ const props = defineProps({
editMode: { editMode: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}); });
defineEmits(["update"]); const emit = defineEmits(["update"]);
const pluginStore = usePluginStore(); const pluginStore = usePluginStore();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -135,9 +171,6 @@ function useStepForm() {
}); });
const stepTypeSelected = (item: any) => { const stepTypeSelected = (item: any) => {
if (item.__online) {
return;
}
if (item.needPlus && !settingStore.isPlus) { if (item.needPlus && !settingStore.isPlus) {
message.warn("此插件需要开通Certd专业版才能使用"); message.warn("此插件需要开通Certd专业版才能使用");
mitter.emit("openVipModal"); mitter.emit("openVipModal");
@@ -306,24 +339,55 @@ function useStepForm() {
}; };
}; };
const pluginSourceActive = ref("local"); const pluginSearch = ref({
const handlePluginConfirm = async (item: any) => { keyword: "",
stepTypeSelected(item); result: [],
await stepTypeSave(); });
}; const pluginGroupActive = ref("all");
const pluginGroup: Ref = ref();
const pluginStore = usePluginStore();
const handleOnlinePluginUninstalled = (plugin: any) => { async function loadPluginGroups() {
if (currentStep.value.type === plugin.fullName) { pluginGroup.value = await pluginStore.getGroups();
currentStep.value.type = undefined; }
currentStep.value.title = "新任务";
loadPluginGroups();
const computedPluginGroups: any = computed(() => {
if (!pluginGroup.value) {
return {};
} }
}; const group = pluginGroup.value as PluginGroups;
const groups = group.groups;
if (pluginSearch.value.keyword) {
const keyword = pluginSearch.value.keyword.toLowerCase();
const list = groups.all.plugins.filter((plugin: any) => {
return plugin.title?.toLowerCase().includes(keyword) || plugin.desc?.toLowerCase().includes(keyword) || plugin.name?.toLowerCase().includes(keyword);
});
return {
search: { key: "search", title: "搜索结果", plugins: list },
};
} else {
return groups;
}
});
watch(
() => {
return pluginSearch.value.keyword;
},
(val: any) => {
if (val) {
pluginGroupActive.value = "search";
} else {
pluginGroupActive.value = "all";
}
}
);
return { return {
pluginSourceActive, pluginGroupActive,
computedPluginGroups,
pluginSearch,
stepTypeSelected, stepTypeSelected,
handlePluginConfirm,
handleOnlinePluginUninstalled,
stepTypeSave, stepTypeSave,
stepFormRef, stepFormRef,
mode, mode,
@@ -372,34 +436,34 @@ const labelCol = ref({ span: 6 });
const wrapperCol = ref({ span: 16 }); const wrapperCol = ref({ span: 16 });
const stepFormRes = useStepForm(); const stepFormRes = useStepForm();
const { const { pluginGroupActive, computedPluginGroups, pluginSearch, stepTypeSelected, stepTypeSave, stepFormRef, stepDrawerVisible, currentStep, currentPlugin, stepSave, stepDelete, getScopeFunc, fullscreen } = stepFormRes;
pluginSourceActive,
stepTypeSelected,
handlePluginConfirm,
handleOnlinePluginUninstalled,
stepTypeSave,
stepFormRef,
stepDrawerVisible,
currentStep,
currentPlugin,
stepSave,
stepDelete,
getScopeFunc,
fullscreen,
} = stepFormRes;
defineExpose({ defineExpose({
...stepFormRes, ...stepFormRes,
}); });
</script> </script>
<style lang="less"> <style lang="less">
.cd-step-form-tab-label {
// dropdown
display: flex;
align-items: center;
//width: 120px;
.fs-icon {
display: flex;
align-items: center;
color: #00b7ff;
svg {
vertical-align: middle !important;
display: flex;
align-items: center;
}
}
}
.step-form-drawer { .step-form-drawer {
max-width: 100%; max-width: 100%;
.ant-drawer-content-wrapper {
max-width: 100vw;
}
.ant-tabs-nav .ant-tabs-tab { .ant-tabs-nav .ant-tabs-tab {
margin-top: 10px !important; margin-top: 10px !important;
padding: 8px 14px !important; padding: 8px 14px !important;
@@ -438,83 +502,6 @@ defineExpose({
} }
.pi-step-form { .pi-step-form {
.step-plugin-source-pane-local,
.step-plugin-source-pane-online {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.step-plugin-search {
flex: none;
padding: 0 0 12px;
}
.step-plugin-selector-tabs {
min-height: 0;
> .ant-tabs-nav {
width: 136px;
flex: 0 0 136px;
overflow: hidden;
}
> .ant-tabs-nav .ant-tabs-nav-wrap,
> .ant-tabs-nav .ant-tabs-nav-list {
width: 100%;
}
> .ant-tabs-nav .ant-tabs-tab {
width: 100%;
box-sizing: border-box;
}
.cd-step-form-tab-label {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
.fs-icon {
display: flex;
align-items: center;
color: #00b7ff;
svg {
vertical-align: middle !important;
display: flex;
align-items: center;
}
}
> div:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
> .ant-tabs-content-holder {
min-width: 0;
min-height: 0;
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding-right: 10px;
}
> .ant-tabs-content-holder > .ant-tabs-content {
height: auto;
min-height: 100%;
}
> .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
padding-right: 0 !important;
overflow: visible !important;
}
}
.bottom-button { .bottom-button {
padding: 20px; padding: 20px;
padding-bottom: 5px; padding-bottom: 5px;
@@ -527,10 +514,6 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
width: 22px;
font-size: 22px;
line-height: 22px;
} }
.body { .body {
@@ -559,9 +542,7 @@ defineExpose({
.ant-card-meta-title { .ant-card-meta-title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
justify-content: flex-start; justify-content: flex-start;
min-height: 22px;
} }
.ant-avatar { .ant-avatar {
@@ -577,21 +558,19 @@ defineExpose({
display: block; display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: 22px;
} }
} }
.ant-card-body { .ant-card-body {
padding: 14px; padding: 14px;
min-height: 100px; height: 100px;
padding-bottom: 6px;
overflow-y: hidden; overflow-y: hidden;
.ant-card-meta-description { .ant-card-meta-description {
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
min-height: 40px; height: 40px;
color: #7f7f7f; color: #7f7f7f;
} }
} }
@@ -1,174 +0,0 @@
<template>
<div class="step-plugin-source-pane-local">
<div class="step-plugin-search">
<a-input-search v-model:value="localPluginSearch.keyword" placeholder="搜索本地插件" :allow-clear="true" :show-search="true"></a-input-search>
</div>
<a-tabs v-model:active-key="pluginGroupActive" tab-position="left" class="step-plugin-selector-tabs flex-1 overflow-hidden h-full">
<a-tab-pane v-for="group of computedLocalPluginGroups" :key="group.key" class="step-plugin-list-pane">
<template #tab>
<div class="cd-step-form-tab-label" @click="handleLocalPluginGroupChange(group.key)">
<fs-icon :icon="group.icon" class="mr-2" />
<div>{{ group.title }}</div>
</div>
</template>
<div v-if="!group.plugins || group.plugins.length === 0" class="step-plugin-empty">没有找到插件</div>
<a-row v-else :gutter="10">
<a-col v-for="item of group.plugins" :key="item.key || item.name" class="step-plugin w-full md:w-[50%]">
<a-card hoverable :class="{ current: item.name === selectedType }" @click="emit('select', item)" @dblclick="emit('confirm', item)">
<a-card-meta>
<template #title>
<fs-icon class="plugin-icon" :icon="item.icon || 'clarity:plugin-line'"></fs-icon>
<span class="title" :title="item.title">{{ item.title }}</span>
<vip-button v-if="item.needPlus" mode="icon" />
</template>
<template #description>
<div class="plugin-card-desc" :title="item.desc" v-html="transformDesc(item.desc)"></div>
</template>
</a-card-meta>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, Ref, watch } from "vue";
import { PluginGroups, usePluginStore } from "/@/store/plugin";
import { useUserStore } from "/@/store/user";
import { utils } from "/@/utils";
defineProps({
selectedType: {
type: String,
default: "",
},
});
const emit = defineEmits<{
(e: "select", plugin: any): void;
(e: "confirm", plugin: any): void;
}>();
const userStore = useUserStore();
const pluginStore = usePluginStore();
const pluginGroupActive = ref("all");
const pluginGroup: Ref<PluginGroups | undefined> = ref();
const localPluginSearch = ref({
keyword: "",
});
function transformDesc(desc: string = "") {
return utils.transformLink(desc);
}
function matchKeyword(plugin: any, keyword: string) {
const fields = [plugin.title, plugin.desc, plugin.name, plugin.fullName, plugin.author, plugin.latest];
return fields.some(field =>
String(field || "")
.toLowerCase()
.includes(keyword)
);
}
async function loadPluginGroups() {
pluginGroup.value = await pluginStore.getGroups();
}
function handleLocalPluginGroupChange(groupKey: string) {
pluginGroupActive.value = groupKey;
localPluginSearch.value.keyword = "";
}
const computedLocalPluginGroups: any = computed(() => {
if (!pluginGroup.value) {
return {};
}
const groups = pluginGroup.value.groups;
const keyword = localPluginSearch.value.keyword.trim().toLowerCase();
const visibleGroups: any = {};
for (const groupKey of Object.keys(groups)) {
if (groupKey === "admin" && !userStore.isAdmin) {
continue;
}
visibleGroups[groupKey] = groups[groupKey];
}
if (!keyword) {
return visibleGroups;
}
const filteredGroups: any = {};
for (const groupKey of Object.keys(visibleGroups)) {
const currentGroup = visibleGroups[groupKey];
filteredGroups[groupKey] = {
...currentGroup,
plugins: currentGroup.plugins.filter((plugin: any) => {
return matchKeyword(plugin, keyword);
}),
};
}
return filteredGroups;
});
watch(
() => {
return localPluginSearch.value.keyword;
},
val => {
if (val) {
pluginGroupActive.value = "all";
}
}
);
loadPluginGroups();
</script>
<style lang="less">
.step-plugin-source-pane-local {
.cd-step-form-tab-label {
display: flex;
align-items: center;
.fs-icon {
display: flex;
align-items: center;
color: #00b7ff;
svg {
vertical-align: middle !important;
display: flex;
align-items: center;
}
}
}
.step-plugin-source-pane {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.step-plugin-search {
flex: none;
padding: 0 0 12px;
}
.step-plugin-empty {
display: flex;
min-height: 320px;
align-items: center;
justify-content: center;
color: #7b8794;
font-size: 13px;
}
.plugin-card-desc {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
</style>
@@ -1,376 +0,0 @@
<template>
<div class="step-plugin-source-pane-online">
<div class="step-plugin-search">
<a-input-search v-model:value="onlinePluginSearch.keyword" placeholder="搜索插件市场" :allow-clear="true" :show-search="true" :loading="onlineLoading" @search="handleOnlinePluginSearch"></a-input-search>
</div>
<a-tabs v-model:active-key="onlinePluginGroupActive" tab-position="left" class="step-plugin-selector-tabs step-market-tabs flex-1 overflow-hidden h-full">
<a-tab-pane v-for="group of computedOnlinePluginGroups" :key="group.key" class="step-plugin-list-pane">
<template #tab>
<div class="cd-step-form-tab-label" @click="handleOnlinePluginGroupChange(group.key)">
<fs-icon :icon="group.icon" class="mr-2" />
<div>{{ group.title }}</div>
</div>
</template>
<div v-if="onlineLoading" class="step-market-state">正在加载插件市场...</div>
<div v-else-if="!group.plugins || group.plugins.length === 0" class="step-plugin-empty">没有找到插件</div>
<template v-else>
<div class="step-market-content">
<a-row :gutter="[12, 12]">
<a-col v-for="item of group.plugins" :key="item.key || item.fullName" class="step-plugin market-plugin-col w-full md:w-[50%]">
<PluginItemCard :plugin="item" simple :current="isOnlinePluginCurrent(item)" @click="onlinePluginSelected(item)" @dblclick="handleOnlinePluginCardDblclick(item)" @changed="handleOnlinePluginChanged" />
</a-col>
</a-row>
<div class="online-plugin-pagination">
<a-pagination v-model:current="onlinePage" size="small" :page-size="onlinePageSize" :total="onlineCurrentTotal" :show-size-changer="false" simple />
</div>
</div>
</template>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, Ref, watch } from "vue";
import { PluginGroups, usePluginStore } from "/@/store/plugin";
import { useUserStore } from "/@/store/user";
import PluginItemCard from "/@/views/sys/plugin/components/plugin-item-card.vue";
import * as pluginApi from "/@/views/sys/plugin/api";
import { useI18n } from "/src/locales";
const props = defineProps({
selectedType: {
type: String,
default: "",
},
});
const emit = defineEmits<{
(e: "select", plugin: any): void;
(e: "confirm", plugin: any): void;
(e: "uninstalled", plugin: any): void;
}>();
const { t } = useI18n();
const userStore = useUserStore();
const pluginStore = usePluginStore();
const pluginGroup: Ref<PluginGroups | undefined> = ref();
const onlinePlugins: Ref<pluginApi.OnlinePluginBean[]> = ref([]);
const onlineGroupPlugins: Ref<pluginApi.OnlinePluginBean[]> = ref([]);
const onlineLoading: Ref<boolean> = ref(false);
const onlineGroupLoaded: Ref<boolean> = ref(false);
const onlinePluginSearch = ref({
keyword: "",
});
const onlinePluginQueryKeyword = ref("");
const onlinePluginGroupActive = ref("all");
const onlinePage = ref(1);
const onlinePageSize = 8;
let onlineRequestId = 0;
async function loadPluginGroups() {
pluginGroup.value = await pluginStore.getGroups();
}
function getOnlinePluginListReq(req?: { groupKey?: string; keyword?: string }) {
const groupKey = req?.groupKey ?? onlinePluginGroupActive.value;
const keyword = (req?.keyword ?? onlinePluginQueryKeyword.value).trim();
const query: { pluginType: string; group?: string; keyword?: string } = {
pluginType: "deploy",
};
if (groupKey && groupKey !== "all") {
query.group = groupKey;
}
if (keyword) {
query.keyword = keyword;
}
return query;
}
async function loadOnlinePluginGroups(force = false) {
if (!userStore.isAdmin) {
return [];
}
if (onlineGroupLoaded.value && !force) {
return onlineGroupPlugins.value;
}
const list = await pluginApi.OnlinePluginList({ pluginType: "deploy" });
onlineGroupPlugins.value = list;
onlineGroupLoaded.value = true;
return list;
}
async function loadOnlinePlugins(force = false, options?: { silent?: boolean }) {
if (!userStore.isAdmin) {
return;
}
const requestId = ++onlineRequestId;
if (!options?.silent) {
onlineLoading.value = true;
}
try {
const groupPlugins = await loadOnlinePluginGroups(force);
if (requestId !== onlineRequestId) {
return;
}
const query = getOnlinePluginListReq();
const isFullQuery = !query.group && !query.keyword;
if (isFullQuery) {
onlinePlugins.value = groupPlugins;
} else {
const list = await pluginApi.OnlinePluginList(query);
if (requestId !== onlineRequestId) {
return;
}
onlinePlugins.value = list;
}
} finally {
if (!options?.silent && requestId === onlineRequestId) {
onlineLoading.value = false;
}
}
}
function getOnlinePluginDisplayName(plugin: pluginApi.OnlinePluginBean) {
if (plugin.fullName) {
return plugin.fullName;
}
if (plugin.author && plugin.name) {
return `${plugin.author}/${plugin.name}`;
}
return plugin.name || "";
}
function buildOnlinePluginCard(plugin: pluginApi.OnlinePluginBean) {
const fullName = getOnlinePluginDisplayName(plugin);
return {
...plugin,
key: `online:${fullName}`,
name: fullName,
fullName,
title: plugin.title || plugin.name || fullName,
desc: plugin.desc,
icon: plugin.icon || "clarity:plugin-line",
__online: true,
};
}
function getOnlinePluginGroupKey(plugin: pluginApi.OnlinePluginBean) {
return (plugin.group || "other").trim() || "other";
}
function getOnlinePluginGroupMeta(groupKey: string) {
const localGroups = pluginGroup.value?.groups || {};
const localGroup = localGroups[groupKey];
if (localGroup) {
return {
title: localGroup.title,
icon: localGroup.icon,
order: localGroup.order,
};
}
if (groupKey === "other") {
return {
title: "其他",
icon: "clarity:plugin-line",
order: 9999,
};
}
return {
title: groupKey,
icon: "clarity:plugin-line",
order: 1000,
};
}
function getFilteredOnlinePlugins() {
return onlinePlugins.value.map(plugin => buildOnlinePluginCard(plugin));
}
function getOnlinePluginsByGroup(groupKey: string) {
const list = getFilteredOnlinePlugins();
if (groupKey === "all") {
return list;
}
return list.filter(plugin => getOnlinePluginGroupKey(plugin) === groupKey);
}
function getPagedOnlinePlugins(groupKey: string) {
const list = getOnlinePluginsByGroup(groupKey);
const start = (onlinePage.value - 1) * onlinePageSize;
return list.slice(start, start + onlinePageSize);
}
async function handleOnlinePluginChanged(payload: { plugin: any; action: string }) {
if (payload.action === "uninstall" && props.selectedType === payload.plugin.fullName) {
emit("uninstalled", payload.plugin);
}
if (payload.action === "install" || payload.action === "uninstall") {
await pluginStore.reload();
}
await loadPluginGroups();
await loadOnlinePlugins(true, { silent: true });
}
function getInstalledPlugin(plugin: any) {
if (!plugin.installed || !plugin.fullName) {
return null;
}
const groups = pluginGroup.value;
if (!groups) {
return null;
}
const candidateNames = [plugin.fullName, plugin.name, plugin.author && plugin.name ? `${plugin.author}/${plugin.name}` : ""].filter(Boolean);
for (const name of candidateNames) {
const installedPlugin = groups.get(name);
if (installedPlugin) {
return installedPlugin;
}
}
return groups.groups.all.plugins.find((item: any) => {
if (plugin.localPluginId && item.id === plugin.localPluginId) {
return true;
}
return item.author === plugin.author && item.name === plugin.name;
});
}
function isOnlinePluginCurrent(plugin: any) {
const installedPlugin = getInstalledPlugin(plugin);
return !!installedPlugin && installedPlugin.name === props.selectedType;
}
function onlinePluginSelected(plugin: any) {
const installedPlugin = getInstalledPlugin(plugin);
if (!installedPlugin) {
return;
}
emit("select", installedPlugin);
}
function handleOnlinePluginCardDblclick(plugin: any) {
const installedPlugin = getInstalledPlugin(plugin);
if (!installedPlugin) {
return;
}
emit("confirm", installedPlugin);
}
function handleOnlinePluginGroupChange(groupKey: string) {
onlinePluginGroupActive.value = groupKey;
onlinePluginSearch.value.keyword = "";
onlinePluginQueryKeyword.value = "";
onlinePage.value = 1;
loadOnlinePlugins();
}
function handleOnlinePluginSearch(value: string) {
onlinePluginQueryKeyword.value = (value || "").trim();
onlinePluginGroupActive.value = "all";
onlinePage.value = 1;
loadOnlinePlugins();
}
const computedOnlinePluginGroups: any = computed(() => {
const allPlugins = onlineGroupPlugins.value.map(plugin => buildOnlinePluginCard(plugin));
const groups: any = {
all: {
key: "all",
title: t("certd.all"),
order: 0,
icon: "material-symbols:border-all-rounded",
plugins: getPagedOnlinePlugins("all"),
},
};
const groupKeys = Array.from(new Set(allPlugins.map(plugin => getOnlinePluginGroupKey(plugin))));
groupKeys.sort((left, right) => {
const leftMeta = getOnlinePluginGroupMeta(left);
const rightMeta = getOnlinePluginGroupMeta(right);
return leftMeta.order - rightMeta.order || left.localeCompare(right);
});
for (const groupKey of groupKeys) {
const meta = getOnlinePluginGroupMeta(groupKey);
groups[groupKey] = {
key: groupKey,
title: meta.title,
order: meta.order,
icon: meta.icon,
plugins: getPagedOnlinePlugins(groupKey),
};
}
return groups;
});
const onlineTotalPage = computed(() => {
const total = getOnlinePluginsByGroup(onlinePluginGroupActive.value).length;
return Math.max(1, Math.ceil(total / onlinePageSize));
});
const onlineCurrentTotal = computed(() => {
return getOnlinePluginsByGroup(onlinePluginGroupActive.value).length;
});
watch(
() => {
return onlinePluginGroupActive.value;
},
() => {
onlinePage.value = 1;
}
);
watch(
() => {
return onlineTotalPage.value;
},
totalPage => {
if (onlinePage.value > totalPage) {
onlinePage.value = totalPage;
}
}
);
loadPluginGroups();
loadOnlinePlugins();
</script>
<style lang="less">
.step-plugin-source-pane-online {
.step-market-content {
min-height: 100%;
padding: 2px 2px 12px 0;
}
.step-market-state {
display: flex;
min-height: 320px;
align-items: center;
justify-content: center;
color: #7b8794;
font-size: 13px;
}
.market-plugin-col {
display: flex;
}
.online-plugin-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 42px;
padding: 10px 2px 0;
color: #5f6b7a;
font-size: 12px;
font-variant-numeric: tabular-nums;
.ant-pagination {
margin: 0;
}
.ant-pagination-simple-pager {
font-variant-numeric: tabular-nums;
}
}
}
</style>
@@ -1,4 +1,4 @@
<template> <template>
<div class="dashboard-user"> <div class="dashboard-user">
<div class="header-profile flex-wrap bg-white dark:bg-black"> <div class="header-profile flex-wrap bg-white dark:bg-black">
<div class="flex flex-1"> <div class="flex flex-1">
@@ -23,9 +23,9 @@
<template v-if="userStore.isAdmin"> <template v-if="userStore.isAdmin">
<a-divider type="vertical" /> <a-divider type="vertical" />
<a-badge :dot="hasNewVersion"> <a-badge :dot="hasNewVersion">
<a-tag color="blue" class="flex-inline pointer mr-0" :title="'v' + version + ' ' + (settingsStore.app.releaseMode === 'stable' ? '稳定版' : '')" @click="openUpgradeUrl()"> <a-tag color="blue" class="flex-inline pointer mr-0" :title="t('certd.dashboard.latestVersion', { version: latestVersion })" @click="openUpgradeUrl()">
<fs-icon icon="ion:rocket-outline" class="mr-5"></fs-icon> <fs-icon icon="ion:rocket-outline" class="mr-5"></fs-icon>
v{{ version }} {{ settingsStore.app.releaseMode === "stable" ? "stable" : "" }} v{{ version }}
</a-tag> </a-tag>
</a-badge> </a-badge>
<a-divider type="vertical" /> <a-divider type="vertical" />
@@ -179,6 +179,7 @@ defineOptions({
const version = ref(import.meta.env.VITE_APP_VERSION); const version = ref(import.meta.env.VITE_APP_VERSION);
const latestVersion = ref(""); const latestVersion = ref("");
function isNewVersion(version: string, latestVersion: string) { function isNewVersion(version: string, latestVersion: string) {
if (!latestVersion) { if (!latestVersion) {
return false; return false;
@@ -13,7 +13,7 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { IframeClient } from "@certd/lib-iframe"; import { IframeClient } from "@certd/lib-iframe";
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useUserStore } from "/@/store/user"; import { useUserStore } from "/@/store/user";
import { useSettingStore } from "/@/store/settings"; import { useSettingStore } from "/@/store/settings";
import * as api from "./api"; import * as api from "./api";
@@ -23,7 +23,6 @@ defineOptions({
name: "AccountBind", name: "AccountBind",
}); });
const iframeRef = ref(); const iframeRef = ref();
let iframeClient: IframeClient | undefined;
const userStore = useUserStore(); const userStore = useUserStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
@@ -41,10 +40,9 @@ type SubjectInfo = {
installAt?: number; installAt?: number;
vipType?: string; vipType?: string;
expiresAt?: number; expiresAt?: number;
userId?: number;
}; };
onMounted(() => { onMounted(() => {
iframeClient = new IframeClient(iframeRef.value, (e: any) => { const iframeClient = new IframeClient(iframeRef.value, (e: any) => {
notification.error({ notification.error({
message: " error", message: " error",
description: e.message, description: e.message,
@@ -56,7 +54,6 @@ onMounted(() => {
installAt: settingStore.installInfo.installTime, installAt: settingStore.installInfo.installTime,
vipType: settingStore.plusInfo.vipType || "free", vipType: settingStore.plusInfo.vipType || "free",
expiresAt: settingStore.plusInfo.expireTime, expiresAt: settingStore.plusInfo.expireTime,
userId: settingStore.installInfo.bindUserId || undefined,
}; };
return subjectInfo; return subjectInfo;
}); });
@@ -87,11 +84,6 @@ onMounted(() => {
}); });
}); });
}); });
onBeforeUnmount(() => {
iframeClient?.destroy();
iframeClient = undefined;
});
</script> </script>
<style lang="less"> <style lang="less">
@@ -1,35 +0,0 @@
import { request } from "/src/api/service";
const apiPrefix = "/sys/audit";
export function createSysAuditApi() {
return {
async GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query,
});
},
async DelObj(id: number) {
return await request({
url: apiPrefix + "/delete",
method: "post",
params: { id },
});
},
async Clean(retentionDays: number) {
return await request({
url: apiPrefix + "/clean",
method: "post",
data: { retentionDays },
});
},
async GetDict() {
return await request({
url: apiPrefix + "/dict",
method: "post",
});
},
};
}
@@ -1,180 +0,0 @@
import { CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, UserPageQuery, UserPageRes, dict } from "@fast-crud/fast-crud";
import { Modal } from "ant-design-vue";
import { useI18n } from "/src/locales";
import { useDicts } from "/@/views/certd/dicts";
const typeDict = dict({
url: "/sys/audit/dict",
getData: async () => {
const { createSysAuditApi } = await import("./api");
const api = createSysAuditApi();
const res = await api.GetDict();
return res.types || [];
},
});
const actionDict = dict({
url: "/sys/audit/dict",
getData: async () => {
const { createSysAuditApi } = await import("./api");
const api = createSysAuditApi();
const res = await api.GetDict();
return res.actions || [];
},
});
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { t } = useI18n();
const { myProjectDict } = useDicts();
const api = context.api;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
const delRequest = async (req: DelReq) => {
return await api.DelObj(req.row.id);
};
const cleanExpired = async () => {
Modal.confirm({
title: "确认清理",
content: "确定要清理90天前的审计日志吗?此操作不可撤销。",
okText: "确认清理",
okType: "danger",
cancelText: "取消",
async onOk() {
await api.Clean(90);
crudExpose.doRefresh();
},
});
};
return {
crudOptions: {
request: { pageRequest, delRequest },
tabs: {
name: "scope",
show: true,
dict: {
data: [
{ value: "system", label: "系统级", color: "red" },
{ value: "user", label: "用户级", color: "blue" },
],
},
},
actionbar: {
buttons: {
add: { show: false },
clean: {
text: "清理过期日志(90天)",
type: "primary",
danger: true,
click: cleanExpired,
},
},
},
rowHandle: {
width: 120,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
remove: { show: true },
copy: { show: false },
},
},
search: {
initialForm: { scope: "system" },
},
columns: {
id: {
title: "ID",
type: "number",
column: { width: 80 },
form: { show: false },
},
createTime: {
title: "操作时间",
type: "datetime",
search: {
show: true,
component: {
name: "a-range-picker",
},
},
column: { width: 170, sorter: true },
form: { show: false },
},
username: {
title: "操作人",
type: "text",
search: { show: true },
column: { width: 120 },
form: { show: false },
},
type: {
title: "类型",
type: "dict-select",
dict: typeDict,
search: { show: true },
column: { width: 120 },
form: { show: false },
},
action: {
title: "操作",
type: "text",
search: { show: true },
column: { width: 200, tooltip: true },
form: { show: false },
},
success: {
title: "结果",
type: "dict-switch",
dict: dict({
data: [
{ value: true, label: "成功", color: "success" },
{ value: false, label: "失败", color: "error" },
],
}),
column: { width: 100, align: "center" },
form: { show: false },
search: { show: true },
},
content: {
title: "备注",
type: "text",
search: { show: true },
column: { width: 500, tooltip: true },
form: { show: false },
},
ipAddress: {
title: "IP地址",
type: "text",
column: { width: 140 },
form: { show: false },
},
projectId: {
title: t("certd.fields.projectName"),
type: "dict-select",
dict: myProjectDict,
form: {
show: false,
},
},
scope: {
title: "范围",
type: "dict-select",
dict: dict({
data: [
{ value: "system", label: "系统级", color: "blue" },
{ value: "user", label: "用户级", color: "green" },
],
}),
search: { show: true },
column: { width: 100 },
form: { show: false },
},
},
},
};
}
@@ -1,24 +0,0 @@
<template>
<fs-page>
<template #header>
<div class="title">
操作日志
<span class="sub">查看所有用户的操作记录</span>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { createSysAuditApi } from "./api";
import { useMounted } from "/@/use/use-mounted";
defineOptions({ name: "SysAuditLog" });
const api = createSysAuditApi();
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: { api } });
useMounted(() => crudExpose.doRefresh());
</script>
@@ -1,35 +1,75 @@
import { request } from "/src/api/service"; import { request } from "/src/api/service";
const apiPrefix = "/sys/audit"; const apiPrefix = "/sys/project/provider";
export function createSysAuditApi() { export async function GetList(query: any) {
return { return await request({
async GetList(query: any) { url: apiPrefix + "/page",
return await request({ method: "post",
url: apiPrefix + "/page", data: query,
method: "post", });
data: query, }
});
}, export async function AddObj(obj: any) {
async DelObj(id: number) { return await request({
return await request({ url: apiPrefix + "/add",
url: apiPrefix + "/delete", method: "post",
method: "post", data: obj,
params: { id }, });
}); }
},
async Clean(retentionDays: number) { export async function UpdateObj(obj: any) {
return await request({ return await request({
url: apiPrefix + "/clean", url: apiPrefix + "/update",
method: "post", method: "post",
data: { retentionDays }, data: obj,
}); });
}, }
async GetDict() {
return await request({ export async function DelObj(id: any) {
url: apiPrefix + "/dict", return await request({
method: "post", url: apiPrefix + "/delete",
}); method: "post",
}, params: { id },
}; });
}
export async function GetObj(id: any) {
return await request({
url: apiPrefix + "/info",
method: "post",
params: { id },
});
}
export async function GetDetail(id: any) {
return await request({
url: apiPrefix + "/detail",
method: "post",
params: { id },
});
}
export async function DeleteBatch(ids: any[]) {
return await request({
url: apiPrefix + "/deleteByIds",
method: "post",
data: { ids },
});
}
export async function SetDefault(id: any) {
return await request({
url: apiPrefix + "/setDefault",
method: "post",
data: { id },
});
}
export async function SetDisabled(id: any, disabled: boolean) {
return await request({
url: apiPrefix + "/setDisabled",
method: "post",
data: { id, disabled },
});
} }
@@ -1,194 +1,252 @@
import { ColumnProps, DataFormatterContext, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, UserPageQuery, UserPageRes, dict } from "@fast-crud/fast-crud"; import * as api from "./api";
import { Modal } from "ant-design-vue";
import { useI18n } from "/src/locales"; import { useI18n } from "/src/locales";
import { useDicts } from "/@/views/certd/dicts"; import { computed, Ref, ref } from "vue";
import { useRouter } from "vue-router";
const typeDict = dict({ import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes, utils } from "@fast-crud/fast-crud";
url: "/sys/audit/dict", import { useUserStore } from "/@/store/user";
getData: async () => { import { useSettingStore } from "/@/store/settings";
const { createSysAuditApi } = await import("./api"); import { Modal } from "ant-design-vue";
const api = createSysAuditApi();
const res = await api.GetDict();
return res.types || [];
},
});
const actionDict = dict({
url: "/sys/audit/dict",
getData: async () => {
const { createSysAuditApi } = await import("./api");
const api = createSysAuditApi();
const res = await api.GetDict();
return res.actions || [];
},
});
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const { myProjectDict } = useDicts();
const api = context.api;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query); return await api.GetList(query);
}; };
const delRequest = async (req: DelReq) => { const editRequest = async ({ form, row }: EditReq) => {
return await api.DelObj(req.row.id); form.id = row.id;
const res = await api.UpdateObj(form);
return res;
};
const delRequest = async ({ row }: DelReq) => {
return await api.DelObj(row.id);
}; };
const cleanExpired = async () => { const addRequest = async ({ form }: AddReq) => {
Modal.confirm({ const res = await api.AddObj(form);
title: "确认清理", return res;
content: "确定要清理90天前的审计日志吗?此操作不可撤销。",
okText: "确认清理",
okType: "danger",
cancelText: "取消",
async onOk() {
await api.Clean(90);
crudExpose.doRefresh();
},
});
}; };
const userStore = useUserStore();
const settingStore = useSettingStore();
const selectedRowKeys: Ref<any[]> = ref([]);
context.selectedRowKeys = selectedRowKeys;
return { return {
crudOptions: { crudOptions: {
request: { pageRequest, delRequest }, settings: {
tabs: { plugins: {
name: "scope", //这里使用行选择插件,生成行选择crudOptions配置,最终会与crudOptions合并
show: true, rowSelection: {
dict: { enabled: true,
data: [ order: -2,
{ value: "system", label: "系统级", color: "red" }, before: true,
{ value: "user", label: "用户级", color: "blue" }, // handle: (pluginProps,useCrudProps)=>CrudOptions,
], props: {
}, multiple: true,
}, crossPage: true,
toolbar: { selectedRowKeys,
buttons: { },
export: { show: true },
},
export: {
dataFrom: "search",
columnFilter: (col: ColumnProps) => col.show === true,
dataFormatter: (opts: DataFormatterContext) => {
const { row, originalRow, col } = opts;
const key = col.key;
if (key === "createTime" && originalRow[key]) {
row[key] = new Date(originalRow[key]).toLocaleString();
}
}, },
}, },
}, },
actionbar: { request: {
buttons: { pageRequest,
add: { show: false }, addRequest,
clean: { editRequest,
text: "清理过期日志(90天)", delRequest,
type: "primary",
danger: true,
click: cleanExpired,
},
},
}, },
rowHandle: { rowHandle: {
width: 120, minWidth: 200,
fixed: "right", fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
remove: { show: true },
copy: { show: false },
},
},
search: {
initialForm: { scope: "system" },
}, },
columns: { columns: {
id: { id: {
title: "ID", title: "ID",
key: "id",
type: "number", type: "number",
column: { width: 80 }, column: {
form: { show: false }, width: 100,
},
createTime: {
title: "操作时间",
type: "datetime",
search: {
show: true,
component: {
name: "a-range-picker",
},
}, },
column: { width: 170, sorter: true },
form: { show: false },
},
username: {
title: "操作人",
type: "text",
search: { show: true },
column: { width: 120 },
form: { show: false },
},
type: {
title: "类型",
type: "dict-select",
dict: typeDict,
search: { show: true },
column: { width: 120 },
form: { show: false },
},
action: {
title: "操作",
type: "text",
search: { show: true },
column: { width: 200, tooltip: true },
form: { show: false },
},
success: {
title: "结果",
type: "dict-switch",
dict: dict({
data: [
{ value: true, label: "成功", color: "success" },
{ value: false, label: "失败", color: "error" },
],
}),
column: { width: 100, align: "center" },
form: { show: false },
search: { show: true },
},
content: {
title: "备注",
type: "text",
search: { show: true },
column: { width: 500, tooltip: true },
form: { show: false },
},
ipAddress: {
title: "IP地址",
type: "text",
column: { width: 140 },
form: { show: false },
},
projectId: {
title: t("certd.fields.projectName"),
type: "dict-select",
dict: myProjectDict,
form: { form: {
show: false, show: false,
}, },
}, },
scope: { domain: {
title: "范围", title: t("certd.cnameDomain"),
type: "text",
editForm: {
component: {
disabled: true,
},
},
search: {
show: true,
},
form: {
component: {
placeholder: t("certd.cnameDomainPlaceholder"),
},
helper: t("certd.cnameDomainHelper"),
rules: [
{ required: true, message: t("certd.requiredField") },
{ pattern: /^[^*]+$/, message: t("certd.cnameDomainPattern") },
],
},
column: {
width: 200,
},
},
dnsProviderType: {
title: t("certd.dnsProvider"),
type: "dict-select",
search: {
show: true,
},
dict: dict({
url: "pi/dnsProvider/list",
value: "key",
label: "title",
}),
form: {
rules: [{ required: true, message: t("certd.requiredField") }],
},
column: {
width: 150,
component: {
color: "auto",
},
},
},
accessId: {
title: t("certd.dnsProviderAuthorization"),
type: "dict-select", type: "dict-select",
dict: dict({
url: "/pi/access/list",
value: "id",
label: "name",
}),
form: {
component: {
name: "access-selector",
vModel: "modelValue",
type: compute(({ form }) => {
return form.dnsProviderType;
}),
},
rules: [{ required: true, message: t("certd.requiredField") }],
},
column: {
width: 150,
component: {
color: "auto",
},
},
},
isDefault: {
title: t("certd.isDefault"),
type: "dict-switch",
dict: dict({ dict: dict({
data: [ data: [
{ value: "system", label: "系统级", color: "blue" }, { label: t("certd.yes"), value: true, color: "success" },
{ value: "user", label: "用户级", color: "green" }, { label: t("certd.no"), value: false, color: "default" },
], ],
}), }),
search: { show: true }, form: {
column: { width: 100 }, value: false,
form: { show: false }, rules: [{ required: true, message: t("certd.selectIsDefault") }],
},
column: {
align: "center",
width: 100,
},
},
setDefault: {
title: t("certd.setDefault"),
type: "text",
form: {
show: false,
},
column: {
width: 100,
align: "center",
conditionalRenderDisabled: true,
cellRender: ({ row }) => {
if (row.isDefault) {
return;
}
const onClick = async () => {
Modal.confirm({
title: t("certd.prompt"),
content: t("certd.confirmSetDefault"),
onOk: async () => {
await api.SetDefault(row.id);
await crudExpose.doRefresh();
},
});
};
return (
<a-button type={"link"} size={"small"} onClick={onClick}>
{t("certd.setAsDefault")}
</a-button>
);
},
},
},
disabled: {
title: t("certd.disabled"),
type: "dict-switch",
dict: dict({
data: [
{ label: t("certd.enabled"), value: false, color: "success" },
{ label: t("certd.disabledLabel"), value: true, color: "error" },
],
}),
form: {
value: false,
},
column: {
width: 100,
component: {
title: t("certd.clickToToggle"),
on: {
async click({ value, row }) {
Modal.confirm({
title: t("certd.prompt"),
content: t("certd.confirmToggleStatus", { action: !value ? t("certd.disable") : t("certd.enable") }),
onOk: async () => {
await api.SetDisabled(row.id, !value);
await crudExpose.doRefresh();
},
});
},
},
},
},
},
createTime: {
title: t("certd.createTime"),
type: "datetime",
form: {
show: false,
},
column: {
sorter: true,
width: 160,
align: "center",
},
},
updateTime: {
title: t("certd.updateTime"),
type: "datetime",
form: {
show: false,
},
column: {
show: true,
width: 160,
},
}, },
}, },
}, },
@@ -1,24 +1,63 @@
<template> <template>
<fs-page> <fs-page class="page-cert">
<template #header> <template #header>
<div class="title"> <div class="title">
操作日志 {{ t("certd.cnameTitle") }}
<span class="sub">查看所有用户的操作记录</span> <span class="sub">
{{ t("certd.cnameDescription") }}
<a href="https://certd.docmirror.cn/guide/feature/cname/" target="_blank">
{{ t("certd.cnameLinkText") }}
</a>
</span>
</div> </div>
</template> </template>
<fs-crud ref="crudRef" v-bind="crudBinding" /> <fs-crud ref="crudRef" v-bind="crudBinding">
<template #pagination-left>
<a-tooltip :title="t('certd.batchDelete')">
<fs-button icon="DeleteOutlined" @click="handleBatchDelete"></fs-button>
</a-tooltip>
</template>
</fs-crud>
</fs-page> </fs-page>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useMounted } from "/@/use/use-mounted";
import { useFs } from "@fast-crud/fast-crud"; import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud"; import createCrudOptions from "./crud";
import { createSysAuditApi } from "./api"; import { message, Modal } from "ant-design-vue";
import { useMounted } from "/@/use/use-mounted"; import { DeleteBatch } from "./api";
import { useI18n } from "/src/locales";
import { useCrudPermission } from "/@/plugin/permission";
defineOptions({ name: "SysAuditLog" }); const { t } = useI18n();
const api = createSysAuditApi(); defineOptions({
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: { api } }); name: "CnameProvider",
useMounted(() => crudExpose.doRefresh()); });
const { crudBinding, crudRef, crudExpose, context } = useFs({ createCrudOptions });
const selectedRowKeys = context.selectedRowKeys;
const handleBatchDelete = () => {
if (selectedRowKeys.value?.length > 0) {
Modal.confirm({
title: t("certd.confirmTitle"),
content: t("certd.confirmDeleteBatch", { count: selectedRowKeys.value.length }),
async onOk() {
await DeleteBatch(selectedRowKeys.value);
message.info(t("certd.deleteSuccess"));
crudExpose.doRefresh();
selectedRowKeys.value = [];
},
});
} else {
message.error(t("certd.selectRecordsFirst"));
}
};
//
useMounted(async () => {
await crudExpose.doRefresh();
});
</script> </script>
<style lang="less"></style>
@@ -82,176 +82,6 @@ export async function ImportPlugin(body: any) {
}); });
} }
export type OnlinePluginBean = {
id?: number;
appId?: number;
developerId?: number;
author?: string;
type?: string;
pluginType?: string;
name?: string;
fullName?: string;
title?: string;
icon?: string;
group?: string;
desc?: string;
latest?: string;
status?: string;
downloadCount?: number;
score?: number;
aiCheckStatus?: string;
selfAuthored?: boolean;
installed?: boolean;
installedVersion?: string;
upgradeAvailable?: boolean;
localPluginId?: number;
localDisabled?: boolean;
localEditable?: boolean;
syncTime?: number;
};
export type OnlinePluginVersionBean = {
id?: number;
pluginId?: number;
version?: string;
minAppVersion?: string;
maxAppVersion?: string;
status?: string;
publishedAt?: number;
reviewStatus?: string;
reviewReason?: string;
reviewedAt?: number;
aiCheckStatus?: string;
aiCheckResult?: string;
};
export type OnlinePluginCommentBean = {
id?: number;
createdAt?: number;
updatedAt?: number;
userId?: number;
score?: number;
comment?: string;
};
export async function OnlinePluginList(body: { pluginType?: string; group?: string; keyword?: string }): Promise<OnlinePluginBean[]> {
return await request({
url: apiPrefix + "/online/list",
method: "post",
data: body,
});
}
export async function OnlinePluginSync(): Promise<OnlinePluginBean[]> {
return await request({
url: apiPrefix + "/online/sync",
method: "post",
});
}
export async function OnlinePluginSetting(): Promise<{ lastSyncTime?: number }> {
return await request({
url: apiPrefix + "/online/setting",
method: "post",
});
}
export async function OnlinePluginInstall(body: { fullName: string; version?: string }) {
return await request({
url: apiPrefix + "/online/install",
method: "post",
data: body,
});
}
export async function OnlinePluginDetail(body: { pluginId?: number; fullName?: string; commentPageNo?: number; commentPageSize?: number }): Promise<{
plugin: OnlinePluginBean;
versions: OnlinePluginVersionBean[];
myScore?: number;
myComment?: string;
comments?: OnlinePluginCommentBean[];
commentsTotal?: number;
}> {
return await request({
url: apiPrefix + "/online/detail",
method: "post",
data: body,
});
}
export async function OnlinePluginSource(body: { pluginId?: number; fullName?: string; version?: string }): Promise<{ plugin: OnlinePluginBean; version: OnlinePluginVersionBean; content: string }> {
return await request({
url: apiPrefix + "/online/source",
method: "post",
data: body,
});
}
export async function OnlinePluginRate(body: { pluginId?: number; fullName?: string; score: number; comment: string }): Promise<{ plugin: OnlinePluginBean; myScore?: number; myComment?: string }> {
return await request({
url: apiPrefix + "/online/rate",
method: "post",
data: body,
});
}
export async function OnlinePluginSubmitVersion(body: { fullName: string; version: string; content: string; minAppVersion?: string; maxAppVersion?: string }) {
return await request({
url: apiPrefix + "/online/version/submit",
method: "post",
data: body,
});
}
export async function OnlinePluginPublish(body: { id: number; version?: string; minAppVersion?: string; maxAppVersion?: string }) {
return await request({
url: apiPrefix + "/online/publish",
method: "post",
data: body,
});
}
export async function OnlinePluginPublishInfo(body: { id: number }): Promise<{
localPlugin: OnlinePluginBean;
authorRegistered?: boolean;
author?: OnlinePluginAuthorBean;
marketPlugin?: OnlinePluginBean;
versions: OnlinePluginVersionBean[];
}> {
return await request({
url: apiPrefix + "/online/publish/info",
method: "post",
data: body,
});
}
export type OnlinePluginAuthorBean = {
id?: number;
appId?: number;
appOwnerId?: number;
developerId?: number;
name?: string;
displayName?: string;
avatar?: string;
desc?: string;
status?: string;
};
export async function OnlinePluginAuthorGet(): Promise<{ registered?: boolean; author?: OnlinePluginAuthorBean }> {
return await request({
url: apiPrefix + "/online/author/get",
method: "post",
});
}
export async function OnlinePluginAuthorAdd(body: { name: string; displayName?: string; avatar?: string; desc?: string }): Promise<OnlinePluginAuthorBean> {
return await request({
url: apiPrefix + "/online/author/add",
method: "post",
data: body,
});
}
export type PluginConfigBean = { export type PluginConfigBean = {
name: string; name: string;
disabled: boolean; disabled: boolean;
@@ -264,9 +94,6 @@ export type CertApplyPluginSysInput = {
googleCommonEabAccessId?: number; googleCommonEabAccessId?: number;
zerosslCommonEabAccessId?: number; zerosslCommonEabAccessId?: number;
litesslCommonEabAccessId?: number; litesslCommonEabAccessId?: number;
googleCommonAcmeAccountAccessId?: number;
zerosslCommonAcmeAccountAccessId?: number;
litesslCommonAcmeAccountAccessId?: number;
}; };
export type PluginSysSetting<T> = { export type PluginSysSetting<T> = {
sysSetting: { sysSetting: {
@@ -1,136 +0,0 @@
<template>
<a-modal v-model:open="visible" :width="'min(1540px, calc(100vw - 48px))'" :footer="null" destroy-on-close class="online-plugin-detail-modal">
<a-spin :spinning="loading">
<iframe v-if="iframeSrc" ref="iframeRef" class="online-plugin-detail-modal__iframe" :src="iframeSrc" @load="handleIframeLoad" />
<a-empty v-else description="插件详情地址未配置" />
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { message } from "ant-design-vue";
import { IframeClient } from "@certd/lib-iframe";
import * as api from "../api";
import { usePluginStore } from "/@/store/plugin";
import { useSettingStore } from "/src/store/settings";
const props = defineProps<{
open: boolean;
plugin?: any;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "installed", value: { plugin: any; version?: string }): void;
}>();
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const iframeRef = ref<HTMLIFrameElement>();
const loading = ref(false);
const visible = computed({
get: () => props.open,
set: value => emit("update:open", value),
});
const iframeSrc = computed(() => {
const plugin = props.plugin;
if (!plugin) {
return "";
}
const baseUrl = String(settingStore.installInfo?.accountServerBaseUrl || "").replace(/\/$/, "");
if (!baseUrl) {
return "";
}
const params = new URLSearchParams({
embedded: "true",
fullName: plugin.fullName || "",
installedVersion: plugin.installedVersion || "",
t: `${Date.now()}`,
});
return `${baseUrl}/#/app/certd/plugin/${plugin.id || 0}?${params.toString()}`;
});
let iframeClient: IframeClient;
watch(
() => props.open,
async open => {
if (!open || !props.plugin) {
loading.value = false;
return;
}
loading.value = true;
await nextTick();
setupIframeClient();
if (!iframeSrc.value) {
loading.value = false;
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
//@ts-ignore
iframeClient?.destroy();
});
function setupIframeClient() {
//@ts-ignore
iframeClient?.destroy();
if (!iframeRef.value) {
return;
}
iframeClient = new IframeClient(iframeRef.value, (error: any) => {
message.error(error?.message || "插件操作失败");
});
iframeClient.register("installPlugin", async req => {
return await installPluginVersion(req.data);
});
}
function handleIframeLoad() {
loading.value = false;
}
async function installPluginVersion(data: { fullName?: string; version?: string }) {
const fullName = data?.fullName || props.plugin?.fullName;
if (!fullName || !data?.version) {
throw new Error("插件版本信息不完整");
}
await api.OnlinePluginInstall({
fullName,
version: data.version,
});
await pluginStore.reload();
emit("installed", {
plugin: props.plugin,
version: data.version,
});
return {
version: data.version,
};
}
</script>
<style lang="less">
.online-plugin-detail-modal {
.ant-modal-body {
height: 80vh;
padding: 0;
overflow: hidden;
}
.ant-spin-nested-loading,
.ant-spin-container {
height: 100%;
}
&__iframe {
display: block;
width: 100%;
height: 80vh;
border: 0;
}
}
</style>
@@ -1,59 +0,0 @@
<template>
<div class="plugin-author-field">
<span v-if="authorName" class="plugin-author-field__name">{{ authorName }}</span>
<a-button v-else type="link" size="small" :loading="loading" @click="registerAuthor">注册作者</a-button>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import * as api from "../api";
import { usePluginPublish } from "../use-publish";
defineOptions({ name: "PluginAuthorField" });
const props = defineProps<{ modelValue?: string }>();
const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>();
const loading = ref(false);
const registeredAuthor = ref<api.OnlinePluginAuthorBean>();
const { registerPluginAuthor } = usePluginPublish();
const authorName = computed(() => props.modelValue || registeredAuthor.value?.name || "");
async function loadAuthor() {
loading.value = true;
try {
const result = await api.OnlinePluginAuthorGet();
registeredAuthor.value = result.author;
if (!props.modelValue && result.author?.name) {
emit("update:modelValue", result.author.name);
}
} catch {
//
} finally {
loading.value = false;
}
}
async function registerAuthor() {
const author = await registerPluginAuthor();
if (!author?.name) {
return;
}
registeredAuthor.value = author;
emit("update:modelValue", author.name);
}
onMounted(loadAuthor);
</script>
<style lang="less">
.plugin-author-field {
display: flex;
min-height: 32px;
align-items: center;
&__name {
color: #1f2937;
}
}
</style>
@@ -1,232 +0,0 @@
<template>
<div class="plugin-edit-dialog-body">
<div class="plugin-edit-dialog-body__header">
<span>插件名称</span>
<fs-copyable :model-value="pluginName" />
<a-button v-if="canEditPlugin" class="plugin-edit-dialog-body__publish" :loading="isPublishingPlugin(plugin)" @click="doPublish">发布到插件市场</a-button>
</div>
<div class="plugin-edit-dialog-body__content">
<section class="plugin-edit-dialog-body__base">
<a-tabs type="card">
<a-tab-pane key="base" tab="插件信息" />
</a-tabs>
<div class="plugin-edit-dialog-body__base-content">
<fs-form ref="baseFormRef" v-bind="formOptionsRef" />
</div>
</section>
<section class="plugin-edit-dialog-body__editor plugin-edit-dialog-body__metadata">
<a-tabs type="card">
<a-tab-pane key="metadata" tab="元数据" />
</a-tabs>
<code-editor :id="`plugin-metadata-${pluginId}`" v-model:model-value="plugin.metadata" language="yaml" @save="doSave" />
</section>
<section class="plugin-edit-dialog-body__editor plugin-edit-dialog-body__script">
<a-tabs type="card">
<a-tab-pane key="script" tab="脚本" />
</a-tabs>
<code-editor :id="`plugin-content-${pluginId}`" v-model:model-value="plugin.content" language="javascript" @save="doSave" />
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, provide, ref, type Ref } from "vue";
import { notification } from "ant-design-vue";
import { useColumns } from "@fast-crud/fast-crud";
// @ts-ignore js-yaml
import yaml from "js-yaml";
import * as api from "../api";
import createCrudOptions from "../crud";
import { usePluginPublish } from "../use-publish";
import { usePluginStore } from "/@/store/plugin";
import { useSettingStore } from "/@/store/settings";
defineOptions({
name: "PluginEditDialogBody",
});
const props = defineProps<{
pluginId: number | string;
}>();
const emit = defineEmits<{
(event: "saved"): void;
}>();
const pluginStore = usePluginStore();
const settingStore = useSettingStore();
const plugin = ref<any>({});
const formOptionsRef: Ref = ref();
const baseFormRef: Ref = ref({});
const saveLoading = ref(false);
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
function initFormOptions() {
const formCrudOptions = createCrudOptions({
// CRUD
crudExpose: {},
context: {},
});
const { buildFormOptions } = useColumns();
const formOptions = buildFormOptions(formCrudOptions.crudOptions, {});
formOptions.mode = "edit";
formOptions.col = { span: 24 };
formOptions.labelCol = { style: { width: "100px" } };
formOptionsRef.value = formOptions;
}
initFormOptions();
const pluginName = computed(() => {
if (plugin.value.author) {
return `${plugin.value.author}/${plugin.value.name}`;
}
return plugin.value.name || "";
});
const canEditPlugin = computed(() => {
const current = plugin.value || {};
if (current.type === "custom") {
return true;
}
if (current.type !== "store") {
return false;
}
const bindUserId = Number(settingStore.installInfo?.bindUserId || 0);
return !current.developerId || (!!bindUserId && Number(current.developerId) === bindUserId);
});
provide("get:plugin", () => plugin);
async function loadPlugin() {
const pluginObj = await api.GetObj(props.pluginId);
plugin.value = pluginObj;
const baseForm = { ...pluginObj };
if (baseForm.extra) {
baseForm.extra = yaml.load(baseForm.extra);
}
delete baseForm.metadata;
delete baseForm.content;
baseFormRef.value.setFormData(baseForm);
}
function validate() {
try {
yaml.load(plugin.value.metadata);
} catch (error: any) {
const message = `元数据校验失败:${error.message}`;
notification.error({ message });
throw new Error(message);
}
}
function buildSubmitForm() {
const baseForm = baseFormRef.value.getFormData();
const form = { ...plugin.value, ...baseForm };
if (form.extra) {
form.extra = yaml.dump(form.extra);
}
return form;
}
async function doSave() {
if (!canEditPlugin.value) {
notification.warning({ message: "当前绑定账号无权编辑该插件" });
return;
}
validate();
saveLoading.value = true;
try {
const form = buildSubmitForm();
await api.UpdateObj(form);
plugin.value = form;
pluginStore.clear();
notification.success({ message: "保存成功" });
emit("saved");
return form;
} finally {
saveLoading.value = false;
}
}
async function doPublish() {
const baseForm = baseFormRef.value.getFormData();
const currentPlugin = { ...plugin.value, ...baseForm };
await publishLocalPlugin(currentPlugin, {
beforePublish: doSave,
async afterPublish() {
await loadPlugin();
},
});
}
onMounted(loadPlugin);
defineExpose({
save: doSave,
});
</script>
<style lang="less">
.plugin-edit-dialog-body {
display: flex;
min-height: 680px;
flex-direction: column;
&__header {
display: flex;
min-height: 32px;
align-items: center;
color: #5f6b7a;
font-size: 13px;
}
&__publish {
margin-left: auto;
}
&__content {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: minmax(260px, 0.72fr) minmax(330px, 1fr) minmax(430px, 1.35fr);
gap: 16px;
margin-top: 12px;
}
&__base,
&__editor {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
&__base {
padding-right: 16px;
border-right: 1px solid #f0f0f0;
}
&__base-content {
min-height: 0;
flex: 1;
overflow: auto;
}
.fs-editor-code {
min-height: 0;
flex: 1;
}
.ant-tabs {
flex: none;
}
}
.dark .plugin-edit-dialog-body {
&__base {
border-right-color: #303030;
}
}
</style>
@@ -41,6 +41,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, Ref, inject, toRef } from "vue"; import { ref, Ref, inject, toRef } from "vue";
import { useFormWrapper } from "@fast-crud/fast-crud";
import yaml from "js-yaml";
const activeKey = ref([]); const activeKey = ref([]);
const getPlugin: any = inject("get:plugin"); const getPlugin: any = inject("get:plugin");
@@ -1,949 +0,0 @@
<template>
<a-card
hoverable
class="plugin-card plugin-item-card"
:class="{
current,
'is-simple': simple,
'is-local': source === 'local',
'is-installed': isInstalled,
'is-disabled': isDisabled,
}"
@click="handleCardClick"
@dblclick="handleCardDoubleClick"
>
<div class="plugin-card__head">
<div class="plugin-card__main">
<fs-icon class="plugin-icon plugin-card__icon" :icon="plugin.icon || 'clarity:plugin-line'" />
<div class="plugin-card__title-wrap">
<a v-if="showEditButton" class="plugin-card__title" :title="plugin.title || plugin.name" href="#" @click.stop.prevent="editPlugin">
{{ plugin.title || plugin.name }}
</a>
<span v-else class="plugin-card__title" :title="plugin.title || plugin.name">{{ plugin.title || plugin.name }}</span>
<a-tooltip v-if="plugin.aiCheckStatus === 'passed'" :title="t('certd.onlinePluginAiReviewPassed')">
<fs-icon class="plugin-card__ai-check-icon" icon="ion:shield-checkmark-outline" />
</a-tooltip>
</div>
</div>
<div class="plugin-card__actions">
<div v-if="showEditButton || showConfigButton || showToolMenu" class="plugin-card__tools">
<a-tooltip v-if="showEditButton" title="编辑">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="editPlugin">
<template #icon>
<fs-icon icon="ion:create-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="copyHandler && canCopyPlugin" title="复制">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="copyPlugin">
<template #icon>
<fs-icon icon="ion:copy-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isOwnImportedPlugin" :title="t('certd.export')">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="exportPlugin">
<template #icon>
<fs-icon icon="ion:download-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canPublishPlugin" :title="t('certd.onlinePluginPublish')">
<a-button class="plugin-card__tool" type="text" size="small" :loading="isPublishingPlugin(plugin)" @click.stop="publishPlugin">
<template #icon>
<fs-icon icon="ion:cloud-upload-outline" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="showConfigButton" title="配置">
<a-button class="plugin-card__tool" type="text" size="small" @click.stop="openConfig">
<template #icon>
<fs-icon icon="ion:settings-outline" />
</template>
</a-button>
</a-tooltip>
</div>
<div v-if="source === 'local' && canRemoveLocal" class="plugin-card__action-zone plugin-card__local-action-zone">
<a-tooltip title="删除">
<a-button class="plugin-card__action-button" type="text" size="small" danger :loading="isActionLoading('remove')" @click.stop="removePlugin">
<template #icon>
<fs-icon icon="ion:trash-outline" />
</template>
</a-button>
</a-tooltip>
</div>
<template v-else-if="plugin.installed && source !== 'local'">
<div v-if="plugin.localPluginId" class="plugin-card__action-zone" :class="{ 'is-loading': isActionLoading('uninstall') }">
<a-tag color="green" class="plugin-status-tag">{{ t("certd.onlinePluginInstalled") }}</a-tag>
<a-button class="plugin-card__action-button" size="small" danger ghost :loading="isActionLoading('uninstall')" @click.stop="uninstallPlugin">
{{ t("certd.onlinePluginUninstall") }}
</a-button>
</div>
<a-tag v-else color="green" class="plugin-status-tag">{{ t("certd.onlinePluginInstalled") }}</a-tag>
</template>
<div v-else-if="source !== 'local'" class="plugin-card__action-zone plugin-card__install-zone" :class="{ 'is-loading': isActionLoading('install') }">
<a-button class="plugin-card__action-button" size="small" type="primary" :loading="isActionLoading('install')" @click.stop="installPlugin">
{{ t("certd.onlinePluginInstall") }}
</a-button>
</div>
</div>
</div>
<div class="plugin-card__desc" :title="plugin.desc">
{{ plugin.desc || "暂无描述" }}
</div>
<div class="plugin-card__meta">
<template v-if="source === 'local'">
<a-tooltip :title="toggleTitle">
<a-tag class="plugin-status-tag" :class="{ 'is-loading': isActionLoading('toggle') }" :color="plugin.disabled ? 'default' : 'green'" @click.stop="togglePlugin">
{{ plugin.disabled ? t("certd.onlinePluginDisabled") : t("certd.onlinePluginEnabled") }}
</a-tag>
</a-tooltip>
<a-tag>{{ pluginTypeLabel }}</a-tag>
<a-tag>{{ sourceLabel }}</a-tag>
<a-tag v-if="plugin.group">{{ plugin.group }}</a-tag>
</template>
<template v-else-if="!simple">
<a-tooltip v-if="plugin.installed && plugin.localPluginId" :title="toggleTitle">
<a-tag class="plugin-status-tag" :class="{ 'is-loading': isActionLoading('toggle') }" :color="plugin.localDisabled ? 'default' : 'green'" @click.stop="togglePlugin">
{{ plugin.localDisabled ? t("certd.onlinePluginDisabled") : t("certd.onlinePluginEnabled") }}
</a-tag>
</a-tooltip>
<a-tag>{{ pluginTypeLabel }}</a-tag>
<a-tag v-if="plugin.group">{{ plugin.group }}</a-tag>
<a-tooltip :title="t('certd.onlinePluginDownloadCount', { count: plugin.downloadCount || 0 })">
<a-tag class="plugin-card__download-count">
<fs-icon icon="ion:cloud-download-outline" />
<span>{{ formatDownloadCount(plugin.downloadCount || 0) }}</span>
</a-tag>
</a-tooltip>
</template>
<a-tooltip v-if="!isBuiltInPlugin" :title="versionTitle">
<span class="plugin-card__version" :class="{ 'is-upgradable': plugin.upgradeAvailable }" @click.stop="handleVersionClick">
v{{ currentVersion }}
<fs-icon v-if="plugin.upgradeAvailable" class="plugin-card__version-icon" icon="carbon:upgrade" />
</span>
</a-tooltip>
<a-tooltip v-if="source !== 'local' && authorName" :title="plugin.selfAuthored ? '这是我自己提交的插件' : `${t('certd.author')}${authorName}`">
<span class="plugin-card__author" :class="{ 'is-self-authored': plugin.selfAuthored }">
<fs-icon :icon="plugin.selfAuthored ? 'ion:person-circle' : 'ion:person-circle-outline'" />
<span>{{ authorName }}</span>
</span>
</a-tooltip>
</div>
</a-card>
</template>
<script lang="ts" setup>
import { computed, h, ref } from "vue";
import { message, Modal } from "ant-design-vue";
import { useFormDialog } from "/@/use/use-dialog";
import { useI18n } from "/src/locales";
import { usePluginStore } from "/@/store/plugin";
import * as api from "../api";
import { usePluginConfig } from "../use-config";
import { usePluginPublish } from "../use-publish";
import PluginEditDialogBody from "./plugin-edit-dialog-body.vue";
defineOptions({
name: "PluginItemCard",
});
const props = withDefaults(
defineProps<{
plugin: any;
source?: "local" | "market";
current?: boolean;
simple?: boolean;
showConfig?: boolean;
editable?: boolean;
copyHandler?: (plugin: any) => void | Promise<void>;
}>(),
{
source: "market",
copyHandler: undefined,
}
);
const emit = defineEmits<{
(e: "click", plugin: any): void;
(e: "dblclick", plugin: any): void;
(e: "changed", payload: { plugin: any; action: PluginCardAction }): void;
}>();
const { t } = useI18n();
const pluginStore = usePluginStore();
const { openConfigDialog } = usePluginConfig();
const { isPublishingPlugin, publishLocalPlugin } = usePluginPublish();
const { openFormDialog } = useFormDialog();
const actionLoading = ref<PluginCardAction | "">("");
const editDialogBodyRef = ref();
type PluginCardAction = "edit" | "copy" | "export" | "publish" | "config" | "install" | "uninstall" | "remove" | "toggle";
const editPluginId = computed(() => {
return props.plugin.localPluginId || props.plugin.id;
});
const canEditPlugin = computed(() => {
if (props.editable != null) {
return props.editable;
}
return props.plugin.type === "custom";
});
const isOwnImportedPlugin = computed(() => {
return props.source === "local" && canEditPlugin.value;
});
const isAvailableLocally = computed(() => {
return props.source === "local" || !!props.plugin.installed;
});
const canCopyPlugin = computed(() => {
return isOwnImportedPlugin.value || (props.source === "market" && !!props.plugin.selfAuthored && isAvailableLocally.value);
});
const showEditButton = computed(() => {
return canEditPlugin.value && isAvailableLocally.value;
});
const showConfigButton = computed(() => {
return !!props.showConfig && isAvailableLocally.value;
});
const isInstalled = computed(() => {
return props.source !== "local" && props.plugin.installed;
});
const isDisabled = computed(() => {
return props.source === "local" ? props.plugin.disabled : props.plugin.localDisabled;
});
const canRemoveLocal = computed(() => {
return isOwnImportedPlugin.value;
});
const isBuiltInPlugin = computed(() => {
return props.source === "local" && props.plugin.type === "builtIn";
});
const canPublishPlugin = computed(() => {
return canEditPlugin.value && isAvailableLocally.value && (!props.plugin.status || !!props.plugin.selfAuthored);
});
const showToolMenu = computed(() => {
return !isBuiltInPlugin.value && (isOwnImportedPlugin.value || canPublishPlugin.value);
});
const fullName = computed(() => {
if (props.plugin.fullName) {
return props.plugin.fullName;
}
if (props.plugin.author && props.plugin.name) {
return `${props.plugin.author}/${props.plugin.name}`;
}
return props.plugin.name || "";
});
const authorName = computed(() => {
if (props.plugin.author) {
return props.plugin.author;
}
const [author] = fullName.value.split("/");
return author || "";
});
const pluginTypeLabel = computed(() => {
const labelMap: Record<string, string> = {
access: t("certd.auth"),
dnsProvider: t("certd.dns"),
deploy: t("certd.deployPlugin"),
};
return labelMap[props.plugin.pluginType || ""] || props.plugin.pluginType || "-";
});
const sourceLabel = computed(() => {
if (props.source === "local" && props.plugin.type !== "builtIn") {
return t("certd.localPlugin");
}
const labelMap: Record<string, string> = {
builtIn: t("certd.builtIn"),
custom: t("certd.custom"),
store: t("certd.store"),
};
return labelMap[props.plugin.type || ""] || props.plugin.type || "-";
});
const currentVersion = computed(() => {
if (props.source === "local") {
return props.plugin.version || props.plugin.latest || "-";
}
return props.plugin.installedVersion || props.plugin.latest || "-";
});
const versionTitle = computed(() => {
if (props.source === "local") {
return `${t("certd.version")} v${currentVersion.value}`;
}
if (props.plugin.installedVersion && props.plugin.upgradeAvailable && props.plugin.latest) {
return `${t("certd.dashboard.latestVersion", { version: `v${props.plugin.latest}` })}${t("certd.onlinePluginClickToUpdate")}`;
}
if (props.plugin.installedVersion) {
return t("certd.onlinePluginAlreadyLatest");
}
if (props.plugin.latest) {
return t("certd.dashboard.latestVersion", { version: `v${props.plugin.latest}` });
}
return `${t("certd.version")} -`;
});
const toggleTitle = computed(() => {
const disabled = props.source === "local" ? props.plugin.disabled : props.plugin.localDisabled;
return disabled ? t("certd.onlinePluginClickToEnable") : t("certd.onlinePluginClickToDisable");
});
function isActionLoading(action: PluginCardAction) {
return actionLoading.value === action;
}
function handleCardClick() {
if (props.source === "local") {
if (showEditButton.value) {
void editPlugin();
}
return;
}
emit("click", props.plugin);
}
function handleCardDoubleClick() {
if (props.source !== "local") {
emit("dblclick", props.plugin);
}
}
function emitChanged(action: PluginCardAction) {
emit("changed", {
plugin: props.plugin,
action,
});
}
async function runAction(action: PluginCardAction, callback: () => Promise<void>, options?: { emitChanged?: boolean }) {
if (actionLoading.value) {
return;
}
actionLoading.value = action;
try {
await callback();
if (options?.emitChanged !== false) {
emitChanged(action);
}
} finally {
actionLoading.value = "";
}
}
async function editPlugin() {
await openFormDialog({
title: `编辑插件 ${props.plugin.title || props.plugin.name}`,
columns: {},
noneForm: true,
wrapper: {
width: 1480,
destroyOnClose: true,
maskClosable: false,
okText: "保存",
cancelText: "关闭",
class: "plugin-edit-dialog",
},
body: () =>
h(PluginEditDialogBody, {
ref: editDialogBodyRef,
pluginId: editPluginId.value,
}),
async onSubmit() {
await editDialogBodyRef.value?.save?.();
emitChanged("edit");
},
});
}
async function copyPlugin() {
if (!props.copyHandler) {
return;
}
await runAction(
"copy",
async () => {
await props.copyHandler?.(props.plugin);
},
{ emitChanged: true }
);
}
async function exportPlugin() {
await runAction(
"export",
async () => {
const content = await api.ExportPlugin(props.plugin.id);
if (!content) {
return;
}
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${props.plugin.name}.yaml`;
link.click();
URL.revokeObjectURL(url);
},
{ emitChanged: false }
);
}
async function publishPlugin() {
await publishLocalPlugin(
{ ...props.plugin, id: editPluginId.value },
{
async afterPublish() {
emitChanged("publish");
},
}
);
}
async function openConfig() {
await openConfigDialog({
row: props.plugin,
onSuccess: async () => {
emitChanged("config");
},
});
}
async function installPlugin() {
const fullName = fullNameValue();
if (!fullName) {
return;
}
await runAction("install", async () => {
await api.OnlinePluginInstall({
fullName,
version: props.plugin.latest,
});
message.success(t("certd.onlinePluginInstallSuccess"));
});
}
function uninstallPlugin() {
if (!props.plugin.localPluginId) {
return;
}
Modal.confirm({
title: t("certd.confirm"),
content: t("certd.onlinePluginDeleteConfirm", { name: fullNameValue() || props.plugin.title || props.plugin.name }),
async onOk() {
await runAction("uninstall", async () => {
await api.DelObj(props.plugin.localPluginId);
message.success(t("certd.onlinePluginUninstallSuccess"));
});
},
});
}
function removePlugin() {
if (!props.plugin.id) {
return;
}
Modal.confirm({
title: t("certd.confirm"),
content: "确定要删除吗?如果该插件已被使用,删除可能会导致流水线执行失败!",
async onOk() {
await runAction("remove", async () => {
await api.DelObj(props.plugin.id);
message.success(t("certd.onlinePluginUninstallSuccess"));
});
},
});
}
function togglePlugin() {
const id = props.source === "local" ? props.plugin.id : props.plugin.localPluginId;
const canToggleBuiltInPlugin = props.source === "local" && props.plugin.type === "builtIn" && !!props.plugin.name;
if (!id && !canToggleBuiltInPlugin) {
return;
}
Modal.confirm({
title: t("certd.confirm"),
content: `${t("certd.confirmToggle")} ${isDisabled.value ? t("certd.enable") : t("certd.disable")}?`,
maskClosable: true,
async onOk() {
await runAction("toggle", async () => {
await api.SetDisabled({
id: id || undefined,
name: props.plugin.name,
type: props.source === "local" ? props.plugin.type : "store",
disabled: !isDisabled.value,
});
await pluginStore.reload();
message.success(t("certd.operationSuccess"));
});
},
});
}
function fullNameValue() {
return fullName.value;
}
function formatDownloadCount(count: number) {
if (count >= 10000) {
return `${(count / 10000).toFixed(1).replace(/\.0$/, "")}w`;
}
return `${count}`;
}
function handleVersionClick() {
if (props.source === "local" || !props.plugin.upgradeAvailable) {
return;
}
Modal.confirm({
title: "升级插件",
content: `确认将 ${props.plugin.title || props.plugin.name} 升级到 v${props.plugin.latest} 吗?`,
okText: "确认升级",
cancelText: "取消",
async onOk() {
await installPlugin();
},
});
}
</script>
<style lang="less">
.plugin-item-card.plugin-card {
width: 100%;
border-color: #e5eaf1;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition-duration: 160ms;
transition-property: border-color, box-shadow, transform, opacity;
transition-timing-function: ease-out;
&:hover {
border-color: #7dc4ff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
&.current {
border-color: #00b7ff;
box-shadow:
0 0 0 2px rgba(0, 183, 255, 0.1),
0 8px 24px rgba(15, 23, 42, 0.08);
}
&.is-installed {
background: linear-gradient(180deg, #ffffff 0%, #f8fffb 100%);
}
&.is-disabled {
opacity: 0.72;
}
.plugin-status-tag {
box-sizing: border-box;
flex: none;
margin: 0;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 24px;
cursor: pointer;
}
.ant-card-body {
display: flex;
min-height: 148px;
flex-direction: column;
padding: 14px 16px 12px;
}
.plugin-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.plugin-card__main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
}
.plugin-card__icon {
flex: none;
margin-top: 0;
}
.plugin-card__main .plugin-icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
color: #00b7ff;
font-size: 22px;
line-height: 22px;
}
.plugin-card__title-wrap {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 4px;
margin-left: 8px;
}
.plugin-card__title {
display: block;
min-width: 0;
flex: 0 1 auto;
max-width: 100%;
overflow: hidden;
color: #1f2937;
font-size: 14px;
font-weight: 600;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
a.plugin-card__title:hover {
color: #1677ff;
}
.plugin-card__author {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 3px;
margin-left: auto;
overflow: hidden;
color: #8c8c8c;
font-size: 12px;
line-height: 18px;
.fs-icon {
flex: none;
font-size: 13px;
line-height: 1;
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-self-authored {
color: #1677ff;
.fs-icon {
color: #1677ff;
}
}
}
.plugin-card__actions {
display: flex;
min-width: max-content;
flex: none;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 1px;
}
.plugin-card__tools {
display: flex;
flex: none;
align-items: center;
gap: 1px;
}
.plugin-card__tool {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 6px;
color: #5f6b7a;
opacity: 0.68;
transition-duration: 160ms;
transition-property: color, background-color, opacity, transform;
transition-timing-function: ease-out;
.fs-icon {
display: flex;
width: 16px;
height: 16px;
flex: none;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
}
&:hover {
color: #1677ff;
opacity: 1;
background: rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
}
}
.plugin-card__action-zone {
position: relative;
width: 68px;
height: 28px;
flex: none;
.plugin-status-tag,
.plugin-card__action-button {
position: absolute;
inset: 0;
display: flex;
height: 28px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
line-height: 26px;
transition-duration: 160ms;
transition-property: opacity, transform, filter;
transition-timing-function: ease-out;
}
.plugin-status-tag {
opacity: 1;
transform: translateY(0) scale(1);
}
.plugin-card__action-button {
opacity: 0;
pointer-events: none;
transform: translateY(4px) scale(0.96);
filter: blur(2px);
}
&.is-loading,
&:hover {
.plugin-status-tag {
opacity: 0;
transform: translateY(-4px) scale(0.96);
filter: blur(2px);
}
.plugin-card__action-button {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
}
.plugin-card__local-action-zone {
width: 28px;
.plugin-card__action-button {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.plugin-card__install-zone {
width: 68px;
.plugin-card__action-button {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.plugin-card__desc {
display: -webkit-box;
min-height: 40px;
margin-top: 8px;
margin-bottom: 8px;
overflow: hidden;
color: #5f6b7a;
font-size: 12px;
line-height: 20px;
text-wrap: pretty;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.plugin-card__meta {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #eef2f7;
.ant-tag {
margin: 0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
}
}
.plugin-card__version {
display: inline-flex;
align-items: center;
gap: 3px;
color: #8c8c8c;
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 22px;
&.is-upgradable {
color: #1677ff;
cursor: pointer;
&:hover {
color: #0958d9;
}
}
}
.plugin-card__version-icon {
font-size: 13px;
line-height: 1;
}
.plugin-card__download-count {
display: inline-flex;
align-items: center;
gap: 3px;
color: #6b7280;
.fs-icon {
font-size: 13px;
line-height: 1;
}
}
.plugin-card__ai-check-icon {
flex: none;
color: #52c41a;
font-size: 16px;
line-height: 1;
}
&.is-simple {
.plugin-card__desc {
min-height: 40px;
margin-bottom: 4px;
-webkit-line-clamp: 2;
}
.plugin-card__meta {
min-height: 24px;
margin-top: 0;
padding-top: 4px;
}
.plugin-card__version {
min-height: 22px;
line-height: 22px;
}
}
}
.dark {
.plugin-item-card.plugin-card {
border-color: #303030;
background: #1f1f1f;
color: rgba(255, 255, 255, 0.85);
&:hover {
border-color: #1668dc;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
}
&.current {
border-color: #1677ff;
box-shadow:
0 0 0 2px rgba(22, 119, 255, 0.18),
0 8px 24px rgba(0, 0, 0, 0.32);
}
&.is-installed {
background: linear-gradient(180deg, #1f1f1f 0%, #18251e 100%);
}
.ant-card-body {
background: transparent;
}
.plugin-card__title {
color: rgba(255, 255, 255, 0.88);
}
a.plugin-card__title:hover {
color: #69b1ff;
}
.plugin-card__author,
.plugin-card__desc,
.plugin-card__version,
.plugin-card__download-count {
color: rgba(255, 255, 255, 0.48);
}
.plugin-card__meta {
border-top-color: #303030;
}
.plugin-card__tool {
color: rgba(255, 255, 255, 0.56);
&:hover {
color: #69b1ff;
background: rgba(22, 119, 255, 0.16);
}
}
.plugin-card__version.is-upgradable {
color: #69b1ff;
&:hover {
color: #91caff;
}
}
}
}
</style>
@@ -114,12 +114,7 @@ const { openConfigDialog } = usePluginConfig();
async function doPluginConfig() { async function doPluginConfig() {
const certApplyInfo = await GetPluginByName("CertApply"); const certApplyInfo = await GetPluginByName("CertApply");
await openConfigDialog({ await openConfigDialog({ row: certApplyInfo, crudExpose: null });
row: certApplyInfo,
onSuccess: async () => {
await loadForm();
},
});
} }
</script> </script>
<style lang="less"></style> <style lang="less"></style>
@@ -49,9 +49,6 @@ import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import Rollbackable from "./rollbackable.vue"; import Rollbackable from "./rollbackable.vue";
import { usePluginStore } from "/@/store/plugin"; import { usePluginStore } from "/@/store/plugin";
import * as api from "./api";
// @ts-ignore js-yaml
import yaml from "js-yaml";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const pluginStore = usePluginStore(); const pluginStore = usePluginStore();
@@ -267,35 +264,12 @@ function clearFormValue(key: string) {
console.log(key, configForm); console.log(key, configForm);
} }
function getPluginOriginName() {
if (props.plugin.fullName) {
return props.plugin.fullName;
}
if (props.plugin.author && props.plugin.name) {
return `${props.plugin.author}/${props.plugin.name}`;
}
return props.plugin.name;
}
async function loadPluginSetting() { async function loadPluginSetting() {
const originName = getPluginOriginName(); currentPlugin.value = await pluginStore.getPluginDefineFromOrigin(props.plugin.name);
currentPlugin.value = await pluginStore.getPluginDefineFromOrigin(originName); for (const key in currentPlugin.value.input) {
if (!currentPlugin.value?.input && originName !== props.plugin.name) {
currentPlugin.value = await pluginStore.getPluginDefineFromOrigin(props.plugin.name);
}
if (!currentPlugin.value?.input && props.plugin.id) {
const plugin = await api.GetObj(props.plugin.localPluginId || props.plugin.id);
const metadata = plugin.metadata ? yaml.load(plugin.metadata) : {};
currentPlugin.value = {
...plugin,
...(metadata || {}),
};
}
const input = currentPlugin.value?.input || {};
for (const key in input) {
configForm[key] = {}; configForm[key] = {};
} }
const setting = currentPlugin.value?.sysSetting || props.plugin.sysSetting; const setting = props.plugin.sysSetting;
if (setting) { if (setting) {
const settingJson = JSON.parse(setting); const settingJson = JSON.parse(setting);
merge(configForm, settingJson.metadata?.input || {}); merge(configForm, settingJson.metadata?.input || {});
@@ -1,6 +1,7 @@
import * as api from "./api"; import * as api from "./api";
import { useI18n } from "/src/locales"; import { useI18n } from "/src/locales";
import { Ref, ref, computed } from "vue"; import { Ref, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { Modal, message } from "ant-design-vue"; import { Modal, message } from "ant-design-vue";
//@ts-ignore //@ts-ignore
@@ -10,9 +11,9 @@ import KvInput from "/@/components/plugins/common/kv-input.vue";
import { usePluginConfig } from "./use-config"; import { usePluginConfig } from "./use-config";
import { useSettingStore } from "/src/store/settings/index"; import { useSettingStore } from "/src/store/settings/index";
import { usePluginStore } from "/@/store/plugin"; import { usePluginStore } from "/@/store/plugin";
import PluginAuthorField from "./components/plugin-author-field.vue";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
let lastType = ""; let lastType = "";
@@ -49,70 +50,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const pluginStore = usePluginStore(); const pluginStore = usePluginStore();
const syncLoading = ref(false);
const lastSyncTime = ref(0);
const autoSyncInterval = 30 * 24 * 60 * 60 * 1000;
const syncButtonTitle = computed(() => {
if (!lastSyncTime.value) {
return t("certd.onlinePluginNotSynced");
}
return t("certd.onlinePluginLastSyncTime", {
time: formatSyncTime(lastSyncTime.value),
});
});
function formatSyncTime(time: number) {
return new Date(time).toLocaleString();
}
function needAutoSync(time: number) {
if (!time) {
return true;
}
return Date.now() - time > autoSyncInterval;
}
function canEditStorePlugin(row: any) {
if (row.type !== "store") {
return false;
}
if (typeof row.localEditable === "boolean") {
return row.localEditable;
}
const bindUserId = Number(settingStore.installInfo?.bindUserId || 0);
return !row.developerId || (!!bindUserId && Number(row.developerId) === bindUserId);
}
async function syncOnlinePlugins(options?: { showSuccess?: boolean }) {
if (syncLoading.value) {
return;
}
syncLoading.value = true;
try {
await api.OnlinePluginSync();
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || Date.now();
await pluginStore.reload();
crudExpose.doRefresh();
if (options?.showSuccess !== false) {
message.success(t("certd.onlinePluginSyncSuccess"));
}
} finally {
syncLoading.value = false;
}
}
async function loadOnlinePluginSetting() {
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || 0;
if (needAutoSync(lastSyncTime.value)) {
await syncOnlinePlugins({ showSuccess: false });
}
}
loadOnlinePluginSetting().catch(e => {
console.warn("load online plugin setting failed", e);
});
return { return {
crudOptions: { crudOptions: {
settings: { settings: {
@@ -152,17 +89,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
await openImportDialog({ crudExpose }); await openImportDialog({ crudExpose });
}, },
}, },
syncOnline: {
show: true,
icon: "ion:sync-outline",
type: "primary",
text: t("certd.onlinePluginSync"),
tooltip: { title: syncButtonTitle },
loading: syncLoading,
async click() {
await syncOnlinePlugins();
},
},
clearRuntimeDeps: { clearRuntimeDeps: {
show: true, show: true,
icon: "ion:trash-outline", icon: "ion:trash-outline",
@@ -184,7 +110,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
}, },
table: { table: {
show: false,
rowKey: "name", rowKey: "name",
remove: { remove: {
afterRemove: async context => { afterRemove: async context => {
@@ -200,26 +125,18 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
buttons: { buttons: {
edit: { edit: {
show: compute(({ row }) => { show: compute(({ row }) => {
return canEditStorePlugin(row); return row.type === "custom";
}), }),
}, },
copy: { copy: {
show: compute(({ row }) => { show: compute(({ row }) => {
return canEditStorePlugin(row); return row.type === "custom";
}), }),
async click({ row }) {
const copyRow = { ...row };
delete copyRow.fullName;
delete copyRow.id;
crudExpose.openCopy({
row: copyRow,
});
},
}, },
remove: { remove: {
order: 999, order: 999,
show: compute(({ row }) => { show: compute(({ row }) => {
return row.type === "custom" || row.type === "store"; return row.type === "custom";
}), }),
}, },
export: { export: {
@@ -228,7 +145,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
title: t("certd.export"), title: t("certd.export"),
type: "link", type: "link",
show: compute(({ row }) => { show: compute(({ row }) => {
return canEditStorePlugin(row); return row.type === "custom";
}), }),
async click({ row }) { async click({ row }) {
const content = await api.ExportPlugin(row.id); const content = await api.ExportPlugin(row.id);
@@ -254,9 +171,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
async click({ row }) { async click({ row }) {
await openConfigDialog({ await openConfigDialog({
row, row,
onSuccess: async () => { crudExpose,
crudExpose.doRefresh();
},
}); });
}, },
}, },
@@ -271,7 +186,16 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
form: { form: {
onSuccess(opts: any) { onSuccess(opts: any) {
crudExpose.doRefresh(); if (opts.res?.id) {
router.push({
name: "SysPluginEdit",
query: {
id: opts.res.id,
},
});
} else {
crudExpose.doRefresh();
}
}, },
}, },
columns: { columns: {
@@ -362,18 +286,19 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
title: t("certd.author"), title: t("certd.author"),
type: "text", type: "text",
search: { search: {
component: {
name: "a-input",
vModel: "value",
},
show: true, show: true,
}, },
form: { form: {
show: true, show: true,
component: { helper: t("certd.authorHelper"),
name: PluginAuthorField, rules: [
vModel: "modelValue", { required: true },
}, {
type: "pattern",
pattern: /^[a-zA-Z][a-zA-Z0-9]+$/,
message: t("certd.authorRuleMsg"),
},
],
}, },
column: { column: {
width: 200, width: 200,
@@ -390,6 +315,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
column: { column: {
width: 300, width: 300,
cellRender({ row }) { cellRender({ row }) {
if (row.type === "custom") {
return <router-link to={`/sys/plugin/edit?id=${row.id}`}>{row.title}</router-link>;
}
return <div>{row.title}</div>; return <div>{row.title}</div>;
}, },
}, },
@@ -410,7 +338,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: true, show: true,
}, },
form: { form: {
value: "store", value: "custom",
component: { component: {
disabled: true, disabled: true,
}, },
@@ -418,6 +346,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
dict: dict({ dict: dict({
data: [ data: [
{ label: t("certd.builtIn"), value: "builtIn" }, { label: t("certd.builtIn"), value: "builtIn" },
{ label: t("certd.custom"), value: "custom" },
{ label: t("certd.store"), value: "store" }, { label: t("certd.store"), value: "store" },
], ],
}), }),
@@ -543,7 +472,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
Modal.confirm({ Modal.confirm({
title: t("certd.confirm"), title: t("certd.confirm"),
content: `${t("certd.confirmToggle")} ${!value ? t("certd.disable") : t("certd.enable")}?`, content: `${t("certd.confirmToggle")} ${!value ? t("certd.disable") : t("certd.enable")}?`,
maskClosable: true,
onOk: async () => { onOk: async () => {
await api.SetDisabled({ await api.SetDisabled({
id: row.id, id: row.id,
@@ -551,7 +479,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
type: row.type, type: row.type,
disabled: !value, disabled: !value,
}); });
crudExpose.doRefresh(); await crudExpose.doRefresh();
}, },
}); });
}, },
@@ -562,9 +490,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
group: { group: {
title: t("certd.pluginGroup"), title: t("certd.pluginGroup"),
type: "dict-select", type: "dict-select",
search: {
show: true,
},
dict: dict({ dict: dict({
url: "/pi/plugin/groupsList", url: "/pi/plugin/groupsList",
label: "title", label: "title",
@@ -0,0 +1,230 @@
<template>
<fs-page class="page-plugin-edit">
<template #header>
<div class="title">
插件编辑
<span class="sub">
<span class="name flex-inline"> 插件名称<fs-copyable :model-value="pluginName"></fs-copyable> </span>
</span>
</div>
<div class="more">
<a-button class="mr-1" type="primary" :loading="saveLoading" @click="doSave">保存</a-button>
</div>
</template>
<div class="pi-plugin-editor">
<div class="base">
<a-tabs type="card">
<a-tab-pane key="base" tab="插件信息"> </a-tab-pane>
</a-tabs>
<div class="base-body">
<fs-form ref="baseFormRef" v-bind="formOptionsRef"></fs-form>
</div>
</div>
<div class="metadata">
<a-tabs type="card">
<a-tab-pane key="editor" tab="元数据"> </a-tab-pane>
</a-tabs>
<div class="metadata-body">
<code-editor :id="`metadata_${idRef}`" v-model:model-value="plugin.metadata" language="yaml" @save="doSave"></code-editor>
</div>
</div>
<div class="script">
<a-tabs type="card">
<a-tab-pane key="script" tab="脚本"> </a-tab-pane>
</a-tabs>
<div class="script-body">
<code-editor :id="`content_${idRef}`" v-model:model-value="plugin.content" language="javascript" @save="doSave"></code-editor>
</div>
</div>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onMounted, provide, ref, Ref, computed } from "vue";
import { useRoute } from "vue-router";
import * as api from "./api";
import { notification } from "ant-design-vue";
import createCrudOptions from "./crud";
import { useColumns } from "@fast-crud/fast-crud";
import { usePluginStore } from "/@/store/plugin";
//@ts-ignore
import yaml from "js-yaml";
defineOptions({
name: "SysPluginEdit",
});
const route = useRoute();
const pluginStore = usePluginStore();
const plugin = ref<any>({});
const formOptionsRef: Ref = ref();
const baseFormRef: Ref = ref({});
function initFormOptions() {
const formCrudOptions = createCrudOptions({
//@ts-ignore
crudExpose: {},
context: {},
});
const { buildFormOptions } = useColumns();
const formOptions = buildFormOptions(formCrudOptions.crudOptions, {});
formOptions.mode = "edit";
formOptions.col = {
span: 24,
};
formOptions.labelCol = {
style: {
width: "100px",
},
};
formOptionsRef.value = formOptions;
}
initFormOptions();
const idRef = ref(route.query.id);
async function getPlugin() {
const id = route.query.id;
const pluginObj = await api.GetObj(id);
plugin.value = pluginObj;
const baseFrom = {
...pluginObj,
};
if (baseFrom.extra) {
baseFrom.extra = yaml.load(baseFrom.extra);
}
delete baseFrom.metadata;
delete baseFrom.content;
baseFormRef.value.setFormData(baseFrom);
}
onMounted(async () => {
getPlugin();
});
const pluginName = computed(() => {
if (!plugin.value) {
return "";
}
if (plugin.value.author) {
return `${plugin.value.author}/${plugin.value.name}`;
}
return plugin.value.name;
});
provide("get:plugin", () => {
return plugin;
});
function validate() {
try {
yaml.load(plugin.value.metadata);
} catch (e: any) {
const message = `元数据校验失败:${e.message}`;
notification.error({
message,
});
throw new Error(message);
}
}
const saveLoading = ref(false);
async function doSave() {
validate();
saveLoading.value = true;
const baseForm = baseFormRef.value.getFormData();
const form = {
...plugin.value,
...baseForm,
};
if (form.extra) {
form.extra = yaml.dump(form.extra);
}
try {
await api.UpdateObj(form);
notification.success({
message: "保存成功",
});
pluginStore.clear();
} finally {
saveLoading.value = false;
}
}
async function doTest() {
await doSave();
const result = await api.DoTest({
id: plugin.value.id,
input: {},
});
notification.success({
message: "测试已开始",
description: result,
});
}
</script>
<style lang="less">
.page-plugin-edit {
.pi-plugin-editor {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
padding: 20px;
.fs-editor-code {
height: 100%;
flex: 1;
}
.base {
width: 400px;
max-width: 30%;
margin-right: 20px;
display: flex;
flex-direction: column;
height: 100%;
.base-body {
flex: 1;
overflow: auto;
}
}
.metadata {
width: 600px;
max-width: 30%;
margin-right: 20px;
display: flex;
flex-direction: column;
height: 100%;
.metadata-body {
height: 100%;
flex: 1;
}
.metadata-editor {
height: 100%;
flex: 1;
.ant-tabs-content {
height: 100%;
}
}
.metadata-source {
height: 100%;
}
}
.script {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.script-body {
flex: 1;
}
}
}
}
</style>
@@ -1,169 +1,57 @@
<template> <template>
<fs-page class="page-sys-plugin"> <fs-page class="page-cert">
<template #header> <template #header>
<div class="title"> <div class="title">
{{ t("certd.pluginManagement") }} {{ t("certd.pluginManagement") }}
<span class="sub">{{ t("certd.pluginBetaWarning") }}</span> <span class="sub">{{ t("certd.pluginBetaWarning") }}</span>
</div> </div>
</template> </template>
<fs-crud ref="crudRef" class="plugin-card-crud" v-bind="crudBinding"> <fs-crud ref="crudRef" v-bind="crudBinding">
<a-empty v-if="pluginList.length === 0" class="plugin-card-empty" /> <!-- <template #pagination-left>-->
<div v-else class="plugin-card-grid"> <!-- <a-tooltip :title="t('certd.batchDelete')">-->
<PluginItemCard <!-- <fs-button icon="DeleteOutlined" @click="handleBatchDelete"></fs-button>-->
v-for="item of pluginList" <!-- </a-tooltip>-->
:key="item.id || item.name" <!-- </template>-->
:source="getPluginCardSource(item)"
:plugin="item"
:show-config="settingStore.isComm"
:editable="canEditPlugin(item)"
:copy-handler="copyPlugin"
@changed="handlePluginChanged"
@click="openPluginDetail"
/>
</div>
</fs-crud> </fs-crud>
<OnlinePluginDetailModal v-model:open="detailVisible" :plugin="detailPlugin" @installed="handleDetailInstalled" />
</fs-page> </fs-page>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useFs } from "@fast-crud/fast-crud"; import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud"; import createCrudOptions from "./crud";
import PluginItemCard from "./components/plugin-item-card.vue"; import { message, Modal } from "ant-design-vue";
import OnlinePluginDetailModal from "./components/online-plugin-detail-modal.vue"; import { DeleteBatch } from "./api";
import { useI18n } from "/src/locales"; import { useI18n } from "/src/locales";
import { useMounted } from "/@/use/use-mounted"; import { useMounted } from "/@/use/use-mounted";
import { computed, ref } from "vue";
import { useSettingStore } from "/src/store/settings";
import { usePluginStore } from "/@/store/plugin";
const { t } = useI18n(); const { t } = useI18n();
const settingStore = useSettingStore();
const pluginStore = usePluginStore();
defineOptions({ defineOptions({
name: "SysPlugin", name: "SysPlugin",
}); });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions }); const { crudBinding, crudRef, crudExpose, context } = useFs({ createCrudOptions });
const detailVisible = ref(false);
const detailPlugin = ref<any>();
const pluginList = computed(() => { const selectedRowKeys = context.selectedRowKeys;
return crudBinding.value?.data || []; const handleBatchDelete = () => {
}); if (selectedRowKeys.value?.length > 0) {
Modal.confirm({
function getPluginCardSource(row: any) { title: t("certd.pluginManagement"),
return row.type === "store" && (row.appId || row.developerId) ? "market" : "local"; content: t("certd.batchDeleteConfirm", { count: selectedRowKeys.value.length }),
} async onOk() {
await DeleteBatch(selectedRowKeys.value);
function canEditPlugin(row: any) { message.info(t("certd.deleteSuccess"));
if (row.type === "custom") {
return true;
}
if (row.type !== "store") {
return false;
}
if (typeof row.localEditable === "boolean") {
return row.localEditable;
}
const bindUserId = Number(settingStore.installInfo?.bindUserId || 0);
return !row.developerId || (!!bindUserId && Number(row.developerId) === bindUserId);
}
function openPluginDetail(row: any) {
if (row.type !== "store" || !(row.fullName || (row.author && row.name))) {
return;
}
detailPlugin.value = row;
detailVisible.value = true;
}
async function handleDetailInstalled() {
crudExpose.doRefresh();
}
async function copyPlugin(row: any) {
const copyRow = { ...row };
delete copyRow.fullName;
delete copyRow.id;
delete copyRow.developerId;
delete copyRow.appId;
delete copyRow.latest;
delete copyRow.status;
delete copyRow.downloadCount;
delete copyRow.score;
await crudExpose.openCopy(
{
row: copyRow,
},
{
async onSuccess() {
crudExpose.doRefresh(); crudExpose.doRefresh();
selectedRowKeys.value = [];
}, },
} });
); } else {
} message.error(t("certd.selectRecordFirst"));
async function handlePluginChanged(payload: { action: string }) {
if (payload.action === "install" || payload.action === "uninstall" || payload.action === "remove" || payload.action === "copy") {
await pluginStore.reload();
} }
crudExpose.doRefresh(); };
}
// //
useMounted(async () => { useMounted(async () => {
await crudExpose.doRefresh(); await crudExpose.doRefresh();
}); });
</script> </script>
<style lang="less"> <style lang="less"></style>
.page-sys-plugin {
.fs-page-content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.plugin-card-crud {
height: 100%;
flex: 1;
min-height: 0;
}
.plugin-card-empty {
padding: 72px 0;
}
.plugin-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 4px 10px 12px;
}
}
@media (max-width: 1400px) {
.page-sys-plugin {
.plugin-card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
}
@media (max-width: 1000px) {
.page-sys-plugin {
.plugin-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
}
@media (max-width: 720px) {
.page-sys-plugin {
.plugin-card-grid {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -12,7 +12,7 @@ export function usePluginConfig() {
const pluginStore = usePluginStore(); const pluginStore = usePluginStore();
// @ts-ignore // @ts-ignore
async function openConfigDialog({ row, onSuccess }) { async function openConfigDialog({ row, crudExpose }) {
const configEditorRef = ref(); const configEditorRef = ref();
function createCrudOptions() { function createCrudOptions() {
return { return {
@@ -33,10 +33,10 @@ export function usePluginConfig() {
}, },
}, },
}, },
async afterSubmit() { afterSubmit() {
notification.success({ message: t("certd.operationSuccess") }); notification.success({ message: t("certd.operationSuccess") });
if (onSuccess) { if (crudExpose) {
await onSuccess(); crudExpose.doRefresh();
} }
}, },
async doSubmit({}: any) { async doSubmit({}: any) {
@@ -66,7 +66,6 @@ export function usePluginImport() {
async doSubmit({ form }: any) { async doSubmit({ form }: any) {
return await api.ImportPlugin({ return await api.ImportPlugin({
...form, ...form,
type: "store",
}); });
}, },
}, },
@@ -1,136 +0,0 @@
.sys-plugin-publish {
margin: 2px;
&__hero {
display: flex;
gap: 16px;
padding: 18px;
border: 1px solid #e4ecf7;
border-radius: 10px;
background: linear-gradient(135deg, #f7fbff 0%, #eef5ff 55%, #f8fbff 100%);
}
&__icon-wrap {
display: flex;
width: 54px;
height: 54px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid rgba(64, 120, 192, 0.16);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 22px rgba(31, 77, 132, 0.08);
}
&__icon {
color: #2f6fbb;
font-size: 30px;
}
&__hero-main {
min-width: 0;
flex: 1;
}
&__title-line,
&__version-line,
&__author-register,
&__review-icons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
&__title-line {
gap: 10px;
}
&__title {
color: #172033;
font-size: 18px;
font-weight: 700;
word-break: break-all;
}
&__desc {
margin-top: 4px;
color: #526172;
line-height: 22px;
word-break: break-all;
}
&__sections,
&__latest-version {
display: grid;
gap: 12px;
}
&__sections {
margin-top: 16px;
}
&__latest-version {
gap: 10px;
}
&__meta-full {
grid-column: 1 / -1;
}
&__version {
color: #1f2937;
font-variant-numeric: tabular-nums;
}
&__review-icons {
gap: 6px;
}
&__review-icon {
display: block;
flex: 0 0 auto;
font-size: 16px;
line-height: 1;
&.is-ai {
&.is-passed {
color: #52c41a;
}
&.is-pending {
color: #faad14;
}
&.is-rejected {
color: #ff4d4f;
}
}
&.is-admin {
color: #1677ff;
&.is-rejected {
color: #ff4d4f;
}
}
}
&__rejected-reasons {
display: grid;
gap: 4px;
padding: 8px 10px;
border-left: 3px solid #ff7875;
border-radius: 4px;
background: #fff2f0;
color: #a61d24;
font-size: 12px;
line-height: 19px;
word-break: break-word;
}
&__prompt {
margin-bottom: 12px;
}
}
@@ -1,511 +0,0 @@
import { reactive, ref } from "vue";
import { message } from "ant-design-vue";
import { useFormDialog } from "/@/use/use-dialog";
import { useI18n } from "/src/locales";
import { usePluginStore } from "/@/store/plugin";
import * as api from "./api";
import "./use-publish.less";
type PublishManagerResult =
| {
action: "publish";
version: string;
}
| {
action: "registered";
}
| {
action: "cancel";
};
export function usePluginPublish() {
const { t } = useI18n();
const { openFormDialog } = useFormDialog();
const pluginStore = usePluginStore();
const publishingPluginId = ref<number | string>("");
const authorNamePattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
function getPluginTypeLabel(pluginType: string) {
const labelMap: Record<string, string> = {
access: t("certd.auth"),
dnsProvider: t("certd.dns"),
deploy: t("certd.deployPlugin"),
};
return labelMap[pluginType] || pluginType || "-";
}
function isPublishingPlugin(row: any) {
return !!row?.id && publishingPluginId.value === row.id;
}
function registerPluginAuthor() {
return new Promise<api.OnlinePluginAuthorBean | undefined>(resolve => {
let resolved = false;
const resolveAuthor = (author?: api.OnlinePluginAuthorBean) => {
if (resolved) {
return;
}
resolved = true;
resolve(author);
};
void openFormDialog({
title: t("certd.onlinePluginAuthorRegister"),
wrapper: {
width: 560,
onClosed() {
resolveAuthor();
},
},
initialForm: {
name: "",
nameConfirm: "",
desc: "",
},
async onSubmit(form: any) {
if (form.name?.trim() !== form.nameConfirm?.trim()) {
throw new Error("两次输入的作者名称不一致");
}
const author = await api.OnlinePluginAuthorAdd({
name: form.name,
displayName: form.displayName,
desc: form.desc,
});
resolveAuthor(author);
},
columns: {
name: {
title: t("certd.onlinePluginAuthorName"),
type: "text",
form: {
col: { span: 24 },
helper: t("certd.onlinePluginAuthorNameHelper"),
rules: [
{ required: true, message: t("certd.onlinePluginAuthorNameRequired") },
{
pattern: authorNamePattern,
message: t("certd.onlinePluginAuthorNameRuleMsg"),
},
],
},
},
nameConfirm: {
title: "确认作者名称",
type: "text",
form: {
col: { span: 24 },
helper: "请再次输入相同名称。作者名称注册后不允许修改。",
rules: [{ required: true, message: "请再次输入作者名称" }],
},
},
},
});
});
}
function getStatusLabel(status?: string) {
const labelMap: Record<string, string> = {
draft: t("certd.onlinePluginStatusDraft"),
reviewing: t("certd.onlinePluginStatusReviewing"),
published: t("certd.onlinePluginStatusPublished"),
rejected: t("certd.onlinePluginStatusRejected"),
offline: t("certd.onlinePluginStatusOffline"),
};
return labelMap[status || ""] || status || "-";
}
function normalizeVersion(version?: string) {
return (version || "").trim();
}
function isReviewingVersion(version: api.OnlinePluginVersionBean) {
return version.status === "reviewing" || version.reviewStatus === "ai_pending" || version.reviewStatus === "pending";
}
function compareVersion(current: string, last: string) {
const currentParts = normalizeVersion(current)
.replace(/^v/i, "")
.split(".")
.map(item => Number.parseInt(item, 10));
const lastParts = normalizeVersion(last)
.replace(/^v/i, "")
.split(".")
.map(item => Number.parseInt(item, 10));
const maxLength = Math.max(currentParts.length, lastParts.length);
for (let index = 0; index < maxLength; index++) {
const currentPart = currentParts[index] || 0;
const lastPart = lastParts[index] || 0;
if (currentPart > lastPart) {
return 1;
}
if (currentPart < lastPart) {
return -1;
}
}
return 0;
}
function findLatestPublishedVersion(versions: api.OnlinePluginVersionBean[], latest?: string) {
const publishedVersions = versions.filter(item => item.status === "published");
const latestText = normalizeVersion(latest);
if (latestText) {
const matched = publishedVersions.find(item => normalizeVersion(item.version) === latestText);
if (matched) {
return matched;
}
}
return publishedVersions[0];
}
function getDefaultPublishVersion(row: any, localPlugin: any, reviewingVersion?: api.OnlinePluginVersionBean) {
const localVersion = normalizeVersion(row.version || localPlugin.version);
if (reviewingVersion) {
return localVersion || normalizeVersion(reviewingVersion.version);
}
return localVersion || "1.0.0";
}
function getVersionError(version: string, baselineVersion?: api.OnlinePluginVersionBean) {
const value = normalizeVersion(version);
if (!value) {
return t("certd.onlinePluginPublishVersionRequired");
}
if (!/^v?\d+(\.\d+)*$/i.test(value)) {
return t("certd.onlinePluginVersionFormatError");
}
if (baselineVersion?.version && compareVersion(value, baselineVersion.version) <= 0) {
return t("certd.onlinePluginVersionBaselineError", { version: baselineVersion.version });
}
return "";
}
function getStatusColor(status?: string) {
const colorMap: Record<string, string> = {
draft: "default",
reviewing: "processing",
published: "success",
rejected: "error",
offline: "warning",
};
return colorMap[status || ""] || "default";
}
function getAiCheckIcon(version: api.OnlinePluginVersionBean) {
if (version.aiCheckStatus === "rejected") {
return "lucide:shield-x";
}
if (version.aiCheckStatus === "pending") {
return "lucide:shield-alert";
}
return "lucide:shield-check";
}
function getAiCheckTooltip(version: api.OnlinePluginVersionBean) {
const result = `${version.aiCheckResult || ""}`.trim();
if (version.aiCheckStatus === "rejected") {
return result ? `AI 安全审查未通过:${result}` : "AI 安全审查未通过";
}
if (version.aiCheckStatus === "pending" || version.reviewStatus === "ai_pending") {
return result ? `AI 安全审查中:${result}` : "AI 安全审查中";
}
return result ? `AI 安全审查已通过:${result}` : "AI 安全审查已通过";
}
function isAdminRejected(version: api.OnlinePluginVersionBean) {
return version.reviewStatus === "rejected" && !!`${version.reviewReason || ""}`.trim();
}
function hasAdminReview(version: api.OnlinePluginVersionBean) {
return version.reviewStatus === "passed" || isAdminRejected(version);
}
function getAdminReviewTooltip(version: api.OnlinePluginVersionBean) {
if (isAdminRejected(version)) {
return `管理员审核已拒绝:${version.reviewReason}`;
}
return "管理员审核已通过";
}
function renderReviewIndicators(version: api.OnlinePluginVersionBean) {
const aiCheckStatus = version.aiCheckStatus || (version.reviewStatus === "ai_pending" ? "pending" : "");
return (
<span class="sys-plugin-publish__review-icons">
{aiCheckStatus ? (
<a-tooltip title={getAiCheckTooltip(version)}>
<fs-icon class={["sys-plugin-publish__review-icon", "is-ai", `is-${aiCheckStatus}`]} icon={getAiCheckIcon({ ...version, aiCheckStatus })} />
</a-tooltip>
) : null}
{hasAdminReview(version) ? (
<a-tooltip title={getAdminReviewTooltip(version)}>
<fs-icon class={["sys-plugin-publish__review-icon", "is-admin", { "is-rejected": isAdminRejected(version) }]} icon={isAdminRejected(version) ? "lucide:user-x" : "lucide:user-check"} />
</a-tooltip>
) : null}
</span>
);
}
function renderRejectedReasons(version: api.OnlinePluginVersionBean) {
const aiReason = version.aiCheckStatus === "rejected" ? `${version.aiCheckResult || ""}`.trim() : "";
if (!aiReason) {
return null;
}
return (
<div class="sys-plugin-publish__rejected-reasons">
<div class="sys-plugin-publish__rejected-reason is-ai">AI {aiReason}</div>
</div>
);
}
function openPublishManager(row: any, info: Awaited<ReturnType<typeof api.OnlinePluginPublishInfo>>) {
const author = info.author;
const marketPlugin = info.marketPlugin;
const versions = info.versions || [];
const localPlugin = info.localPlugin || row;
const reviewingVersion = versions.find(isReviewingVersion);
const rejectedVersion = versions.find(item => item.status === "rejected" || item.reviewStatus === "rejected");
const replaceableVersion = reviewingVersion || rejectedVersion;
const latestPublished = findLatestPublishedVersion(versions, marketPlugin?.latest);
const latestVersion = versions[0];
const isFirstPublish = !marketPlugin && versions.length === 0;
const publishMode = isFirstPublish ? "first" : replaceableVersion ? "cover" : "new";
const defaultVersion = getDefaultPublishVersion(row, localPlugin, replaceableVersion);
const versionBaseline = publishMode === "cover" ? latestPublished : latestVersion;
const state = reactive({
version: defaultVersion,
error: getVersionError(defaultVersion, versionBaseline),
});
let formWrapper: any;
let resolved = false;
let resolveResult: (result: PublishManagerResult) => void;
function getPublishModeTitle() {
if (publishMode === "first") {
return "首次发布";
}
if (publishMode === "cover") {
return "覆盖待审核版本";
}
return "发布新版本";
}
function getPublishModeTip() {
if (publishMode === "first") {
return "插件还没有提交到在线市场,确认后会创建插件并提交发布审核。";
}
if (publishMode === "cover") {
if (rejectedVersion && rejectedVersion === replaceableVersion) {
return `当前 v${rejectedVersion.version || "-"} 已被拒绝,本次提交可覆盖该版本并重新提交审核。`;
}
return `当前已有 v${reviewingVersion?.version || "-"} 正在审核中,本次提交会撤回并覆盖该审核版本。`;
}
return `当前最新版本为 v${latestVersion?.version || latestPublished?.version || marketPlugin?.latest || "-"},新提交的版本号必须更高。`;
}
function renderMetaItem(label: string, value: any) {
return (
<div class="cd-meta-item">
<span class="cd-meta-label">{label}</span>
<span class="cd-meta-value">{value || "-"}</span>
</div>
);
}
function renderAuthor() {
if (info.authorRegistered) {
return author?.displayName || author?.name || "-";
}
return (
<span class="sys-plugin-publish__author-register">
<span class="cd-text-danger">{t("certd.onlinePluginAuthorNotRegistered")}</span>
<a-button
size="small"
type="link"
onClick={async (event: any) => {
event.stopPropagation();
const createdAuthor = await registerPluginAuthor();
if (!createdAuthor?.id) {
return;
}
resolveResult({ action: "registered" });
formWrapper?.close?.();
}}
>
{t("certd.onlinePluginAuthorRegister")}
</a-button>
</span>
);
}
function renderLatestVersion() {
if (!latestVersion) {
return <div class="cd-text-muted">{t("certd.onlinePluginNoSubmittedVersion")}</div>;
}
return (
<div class="sys-plugin-publish__latest-version">
<div class="sys-plugin-publish__version-line">
<strong class="sys-plugin-publish__version">v{latestVersion.version || "-"}</strong>
<a-tag color={getStatusColor(latestVersion.status)}>{getStatusLabel(latestVersion.status)}</a-tag>
{renderReviewIndicators(latestVersion)}
</div>
{renderRejectedReasons(latestVersion)}
</div>
);
}
function renderContent() {
return (
<div class="sys-plugin-publish">
<div class="sys-plugin-publish__hero">
<div class="sys-plugin-publish__icon-wrap">
<fs-icon class="sys-plugin-publish__icon" icon={localPlugin.icon || row.icon || "clarity:plugin-line"} />
</div>
<div class="sys-plugin-publish__hero-main">
<div class="sys-plugin-publish__title-line">
<div class="sys-plugin-publish__title">{localPlugin.title || row.title || localPlugin.name || row.name || "-"}</div>
<a-tag color="blue">{getPublishModeTitle()}</a-tag>
</div>
<div class="sys-plugin-publish__desc">{localPlugin.desc || row.desc || "暂无描述"}</div>
</div>
</div>
<div class="sys-plugin-publish__sections">
<div class="cd-card-section">
<div class="cd-card-section-title"></div>
<div class="cd-meta-grid">
{renderMetaItem(t("certd.pluginName"), localPlugin.name || row.name)}
{renderMetaItem(t("certd.pluginType"), getPluginTypeLabel(localPlugin.pluginType || row.pluginType))}
{renderMetaItem(t("certd.pluginGroup"), localPlugin.group || row.group)}
{renderMetaItem(t("certd.onlinePluginPublishStatus"), marketPlugin ? getStatusLabel(marketPlugin.status) : t("certd.onlinePluginNotSubmitted"))}
<div class="sys-plugin-publish__meta-full">{renderMetaItem(t("certd.author"), renderAuthor())}</div>
</div>
</div>
<div class="cd-card-section">
<div class="cd-card-section-title">{t("certd.onlinePluginLatestVersion")}</div>
{renderLatestVersion()}
</div>
<div class="cd-card-section">
<div class="cd-card-section-title cd-card-section-title--compact">{t("certd.onlinePluginCurrentRelease")}</div>
<div class={["cd-tip-box sys-plugin-publish__prompt", { "cd-tip-box-warning": publishMode === "cover" }]}>
<div class="cd-tip-title">{t("certd.onlinePluginPublishPrompt")}</div>
<div>{getPublishModeTip()}</div>
</div>
<div class="cd-form-row">
<label class="cd-form-label">{t("certd.version")}</label>
<div>
<input
type="text"
value={state.version}
placeholder="请输入版本号,例如 1.0.1"
class={["cd-text-input", { "cd-text-input-error": state.error }]}
onInput={(event: any) => {
state.version = event?.target?.value || "";
state.error = getVersionError(state.version, versionBaseline);
}}
/>
{state.error ? <div class="cd-field-error">{state.error}</div> : null}
</div>
</div>
</div>
</div>
</div>
);
}
return new Promise<PublishManagerResult>(resolve => {
resolveResult = (result: PublishManagerResult) => {
if (resolved) {
return;
}
resolved = true;
resolve(result);
};
void openFormDialog({
title: marketPlugin ? t("certd.onlinePluginPublishManage") : t("certd.onlinePluginPublish"),
columns: {},
body: () => renderContent(),
wrapper: {
width: 920,
buttons: {
reset: {
show: false,
},
ok: {
show: true,
text: t("certd.onlinePluginPublishSubmit"),
disabled: !info.authorRegistered,
},
cancel: {
show: true,
text: "取消",
},
},
onClosed() {
resolveResult({ action: "cancel" });
},
},
async onSubmit() {
if (!info.authorRegistered) {
throw new Error(t("certd.onlinePluginAuthorNotRegistered"));
}
state.error = getVersionError(state.version, versionBaseline);
if (state.error) {
throw new Error(state.error);
}
resolveResult({ action: "publish", version: normalizeVersion(state.version) });
},
}).then(wrapper => {
formWrapper = wrapper;
});
});
}
async function publishLocalPlugin(row: any, options?: { beforePublish?: () => Promise<any>; afterPublish?: () => Promise<void> }) {
if (!row?.id || (row.type !== "store" && row.type !== "custom")) {
return;
}
const info = await api.OnlinePluginPublishInfo({
id: row.id,
});
const action = await openPublishManager(row, info);
if (action.action === "cancel") {
return;
}
if (action.action === "registered") {
await publishLocalPlugin(row, options);
return;
}
if (action.action !== "publish") {
return;
}
publishingPluginId.value = row.id;
try {
const publishRow = (await options?.beforePublish?.()) || row;
await api.OnlinePluginPublish({
id: publishRow.id,
version: action.version,
});
if (normalizeVersion(publishRow.version) !== action.version) {
await api.UpdateObj({
...publishRow,
version: action.version,
});
}
await pluginStore.reload();
await options?.afterPublish?.();
message.success(t("certd.onlinePluginPublishSuccess"));
} finally {
publishingPluginId.value = "";
}
}
return {
isPublishingPlugin,
publishLocalPlugin,
registerPluginAuthor,
};
}
+1 -2
View File
@@ -1,4 +1,3 @@
LEGO_VERSION=4.30.1 LEGO_VERSION=4.30.1
JKS_GO_VERSION=1.0.3 JKS_GO_VERSION=1.0.3
certd_plugin_loadmode=dev certd_plugin_loadmode=dev
# certd_release_mode=stable
@@ -30,4 +30,4 @@ typeorm:
account: account:
server: server:
baseUrl: 'http://localhost:1017/subject' baseUrl: 'http://127.0.0.1:1017/subject'
@@ -26,9 +26,8 @@ typeorm:
# #
#plus: #plus:
# server: # server:
# baseUrls: ['http://localhost:11007'] # baseUrls: ['http://127.0.0.1:11007']
# #
#account: #account:
# server: # server:
# baseUrl: 'http://localhost:1017/subject' # baseUrl: 'http://127.0.0.1:1017/subject'
+1 -1
View File
@@ -17,4 +17,4 @@ typeorm:
# #
#account: #account:
# server: # server:
# baseUrl: 'http://localhost:1017/subject' # baseUrl: 'http://127.0.0.1:1017/subject'
@@ -1,8 +0,0 @@
ALTER TABLE `cd_audit_log` ADD COLUMN `scope` varchar(32) NOT NULL DEFAULT 'user';
CREATE INDEX `index_audit_log_scope` ON `cd_audit_log` (`scope`);
ALTER TABLE `cd_audit_log` ADD COLUMN `success` tinyint(1) NOT NULL DEFAULT 1;
-- 审计日志表索引
CREATE INDEX `index_audit_log_type` ON `cd_audit_log` (`type`);
CREATE INDEX `index_audit_log_create_time` ON `cd_audit_log` (`create_time`);
@@ -1,8 +0,0 @@
ALTER TABLE "cd_audit_log" ADD COLUMN "scope" varchar(32) NOT NULL DEFAULT ('user');
CREATE INDEX "index_audit_log_scope" ON "cd_audit_log" ("scope");
ALTER TABLE "cd_audit_log" ADD COLUMN "success" boolean NOT NULL DEFAULT true;
-- 审计日志表索引
CREATE INDEX "index_audit_log_type" ON "cd_audit_log" ("type");
CREATE INDEX "index_audit_log_create_time" ON "cd_audit_log" ("create_time");
@@ -1,10 +0,0 @@
ALTER TABLE "cd_audit_log" ADD COLUMN "scope" varchar(32) NOT NULL DEFAULT ('user');
CREATE INDEX "index_audit_log_scope" ON "cd_audit_log" ("scope");
ALTER TABLE "cd_audit_log" ADD COLUMN "success" boolean NOT NULL DEFAULT (1);
-- 删除project_name字段
ALTER TABLE "cd_audit_log" DROP COLUMN "project_name";
-- 审计日志表索引
CREATE INDEX "index_audit_log_type" ON "cd_audit_log" ("type");
CREATE INDEX "index_audit_log_create_time" ON "cd_audit_log" ("create_time");
@@ -1,16 +0,0 @@
ALTER TABLE "pi_plugin" ADD COLUMN "app_id" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "developer_id" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "full_name" varchar(200);
ALTER TABLE "pi_plugin" ADD COLUMN "latest" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "status" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "download_count" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "score" real;
ALTER TABLE "pi_plugin" ADD COLUMN "sync_time" integer;
CREATE UNIQUE INDEX "index_plugin_full_name" ON "pi_plugin" ("full_name");
CREATE INDEX "index_plugin_plugin_type" ON "pi_plugin" ("pluginType");
CREATE INDEX "index_plugin_group" ON "pi_plugin" ("group");
CREATE INDEX "index_plugin_developer_id" ON "pi_plugin" ("developer_id");
CREATE INDEX "index_plugin_sync_time" ON "pi_plugin" ("sync_time");
UPDATE "pi_plugin" SET "type" = 'store' WHERE "type" = 'custom';
@@ -1,5 +0,0 @@
ALTER TABLE "pi_plugin" ADD COLUMN "installed" boolean NOT NULL DEFAULT true;
UPDATE "pi_plugin" SET "installed" = true;
ALTER TABLE "pi_plugin" ADD COLUMN "ai_check_status" varchar(32) NOT NULL DEFAULT '';
+1 -1
View File
@@ -9,7 +9,7 @@
"dev-start": "cross-env NODE_ENV=dev node --optimize-for-size ./bootstrap.js", "dev-start": "cross-env NODE_ENV=dev node --optimize-for-size ./bootstrap.js",
"dc": "cd ../../../ && pnpm run dev", "dc": "cd ../../../ && pnpm run dev",
"dev": "cross-env NODE_ENV=dev mwtsc --watch --run @midwayjs/mock/app", "dev": "cross-env NODE_ENV=dev mwtsc --watch --run @midwayjs/mock/app",
"dev-localcomm": "cross-env NODE_ENV=dev-localcomm mwtsc --watch --run @midwayjs/mock/app", "dev-commlocal": "cross-env NODE_ENV=dev-commlocal mwtsc --watch --run @midwayjs/mock/app",
"dev-commpro": "cross-env NODE_ENV=dev-commpro mwtsc --watch --run @midwayjs/mock/app", "dev-commpro": "cross-env NODE_ENV=dev-commpro mwtsc --watch --run @midwayjs/mock/app",
"dev-pg": "cross-env NODE_ENV=dev-pg mwtsc --watch --run @midwayjs/mock/app", "dev-pg": "cross-env NODE_ENV=dev-pg mwtsc --watch --run @midwayjs/mock/app",
"dev-mysql": "cross-env NODE_ENV=dev-mysql mwtsc --watch --run @midwayjs/mock/app", "dev-mysql": "cross-env NODE_ENV=dev-mysql mwtsc --watch --run @midwayjs/mock/app",
@@ -12,7 +12,6 @@ import cors from "@koa/cors";
import { GlobalExceptionMiddleware } from "./middleware/global-exception.js"; import { GlobalExceptionMiddleware } from "./middleware/global-exception.js";
import { PreviewMiddleware } from "./middleware/preview.js"; import { PreviewMiddleware } from "./middleware/preview.js";
import { AuthorityMiddleware } from "./middleware/authority.js"; import { AuthorityMiddleware } from "./middleware/authority.js";
import { AuditLogMiddleware } from "./middleware/audit-log.js";
import { logger } from "@certd/basic"; import { logger } from "@certd/basic";
import { ResetPasswdMiddleware } from "./middleware/reset-passwd/middleware.js"; import { ResetPasswdMiddleware } from "./middleware/reset-passwd/middleware.js";
import DefaultConfig from "./config/config.default.js"; import DefaultConfig from "./config/config.default.js";
@@ -42,10 +41,10 @@ process.on("uncaughtException", error => {
// } // }
// setInterval(log, 200); // setInterval(log, 200);
// log() // log()
// }127.0.0.1 // }
// startHeapLog(); // startHeapLog();
@Configuration({ @Configuration({
detectorOptions: { detectorOptions: {
ignore: ["**/plugins/**"], ignore: ["**/plugins/**"],
@@ -114,7 +113,6 @@ export class MainConfiguration {
PreviewMiddleware, PreviewMiddleware,
//授权处理 //授权处理
AuthorityMiddleware, AuthorityMiddleware,
AuditLogMiddleware,
//resetPasswd,重置密码模式下不提供服务 //resetPasswd,重置密码模式下不提供服务
ResetPasswdMiddleware, ResetPasswdMiddleware,
@@ -3,7 +3,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { getReleaseMode, normalizeReleaseVersion } from "./app-controller.js"; import { normalizeReleaseVersion } from "./app-controller.js";
describe("AppController.normalizeReleaseVersion", () => { describe("AppController.normalizeReleaseVersion", () => {
it("normalizes AtomGit release tag names", () => { it("normalizes AtomGit release tag names", () => {
@@ -15,29 +15,3 @@ describe("AppController.normalizeReleaseVersion", () => {
assert.equal(normalizeReleaseVersion({ name: "v1.40.0" }), "1.40.0"); assert.equal(normalizeReleaseVersion({ name: "v1.40.0" }), "1.40.0");
}); });
}); });
describe("AppController.getReleaseMode", () => {
it("returns 'latest' when env var is not set", () => {
delete process.env.certd_release_mode;
assert.equal(getReleaseMode(), "latest");
});
it("returns 'latest' when env var is empty", () => {
process.env.certd_release_mode = "";
assert.equal(getReleaseMode(), "latest");
});
it("returns 'stable' when env var is 'stable'", () => {
process.env.certd_release_mode = "stable";
assert.equal(getReleaseMode(), "stable");
});
it("returns 'latest' when env var is an unknown value", () => {
process.env.certd_release_mode = "unknown";
assert.equal(getReleaseMode(), "latest");
});
after(() => {
delete process.env.certd_release_mode;
});
});

Some files were not shown because too many files have changed in this diff Show More