feat: 添加 PocketBase MiniApp 公司 API 文档和文件字段迁移脚本
- 新增 openapi-miniapp-company.yaml 文件,定义 tbl_company 的基础 CRUD 接口文档,包括查询、创建、更新和删除公司记录的详细描述和示例。 - 新增 pocketbase.file-fields-to-attachments.js 脚本,用于迁移 PocketBase 中的文件字段到文本字段,并处理 tbl_attachments 集合的公开规则。
This commit is contained in:
156
.codex/skills/openspec-apply-change/SKILL.md
Normal file
156
.codex/skills/openspec-apply-change/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
114
.codex/skills/openspec-archive-change/SKILL.md
Normal file
114
.codex/skills/openspec-archive-change/SKILL.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
288
.codex/skills/openspec-explore/SKILL.md
Normal file
288
.codex/skills/openspec-explore/SKILL.md
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
110
.codex/skills/openspec-propose/SKILL.md
Normal file
110
.codex/skills/openspec-propose/SKILL.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/back-end/node_modules
|
||||
/.tmp-upload-probe
|
||||
|
||||
104
docs/ARCHIVE.md
104
docs/ARCHIVE.md
@@ -2,6 +2,110 @@
|
||||
|
||||
## 归档日期
|
||||
|
||||
- 2026-03-28
|
||||
|
||||
## 归档范围
|
||||
|
||||
本次归档覆盖 PocketBase hooks 项目在附件统一存储、文档管理页增强、字典项图片、用户图片字段附件化、SDK 直连权限页、文档文件字段 `document_file`、大文件上传 body limit 修复、以及对应 OpenAPI / 表结构文档同步方面的工作,涉及:
|
||||
|
||||
- 所有业务文件统一收敛到 `tbl_attachments`
|
||||
- 业务表只保存 `attachments_id`,并由 hooks 联表返回文件流链接
|
||||
- `tbl_document` 新增 `document_file`,与 `document_image`、`document_video` 一样支持多附件和 `|` 分隔存储
|
||||
- `manage/document-manage` 页面新增文件附件区、初始隐藏编辑区、保存后保持当前编辑态、局部状态提示、拖拽上传与全屏图片预览
|
||||
- `manage/dictionary-manage` 页面支持字典枚举项图片上传和全屏查看原图
|
||||
- `manage/sdk-permission-manage` 页面支持按角色配置 collection CRUD 直连权限,并优化为即时保存
|
||||
- 上传路由显式放宽 PocketBase custom route `bodyLimit`
|
||||
- 使用 `POCKETBASE_AUTH_TOKEN` 在线补齐 `tbl_document.document_file`
|
||||
- 新增 OpenSpec 归档目录 `openspec/changes/archive/2026-03-28-pocketbase-manage-media-and-sdk-permissions/`
|
||||
|
||||
---
|
||||
|
||||
## 一、附件集中存储
|
||||
|
||||
### 1. 附件表作为唯一文件存储点
|
||||
|
||||
- 仅 `tbl_attachments.attachments_link` 保留真实 `file` 字段
|
||||
- 其他业务表不再保存实际文件
|
||||
- 文档、字典、用户图片字段统一改为保存 `attachments_id`
|
||||
|
||||
### 2. 附件回读策略
|
||||
|
||||
hooks 查询时统一联查 `tbl_attachments`,并补充:
|
||||
|
||||
- 文件流链接
|
||||
- 下载链接
|
||||
- 附件元数据对象
|
||||
|
||||
适用对象包括:
|
||||
|
||||
- 文档图片 / 视频 / 文件
|
||||
- 字典枚举项图片
|
||||
- 用户头像 / 身份图 / 资质图
|
||||
|
||||
### 3. 附件访问控制
|
||||
|
||||
- PocketBase 原生 `tbl_attachments` 已放开公开读取与下载
|
||||
- hooks 层原有 `ManagePlatform` 限制保持不变
|
||||
- 业务访问控制继续由保存附件 ID 的业务表承担
|
||||
|
||||
---
|
||||
|
||||
## 二、文档管理增强
|
||||
|
||||
### 1. `tbl_document` 新增 `document_file`
|
||||
|
||||
- 新字段:`document_file`
|
||||
- 类型:`text`
|
||||
- 用途:保存多个文件类 `attachments_id`
|
||||
- 存储格式:`id1|id2|id3`
|
||||
|
||||
### 2. 文档管理页行为
|
||||
|
||||
- 首次进入页面时只显示列表,不显示编辑区
|
||||
- 点击“新建模式”或“编辑”后才进入编辑区
|
||||
- 保存成功后保持当前文档编辑态,不再强制清空回到新建
|
||||
- 保存 / 报错信息同时显示在顶部与保存按钮下方
|
||||
- 图片、视频、文件三块附件区都支持拖拽上传
|
||||
|
||||
### 3. 上传链路修复
|
||||
|
||||
- 上传接口继续使用 `/pb/api/attachment/upload`
|
||||
- 为自定义路由显式增加更大的 `bodyLimit`
|
||||
- 解决了文件未超过数据库与网关限制但仍在 hooks 层被 413 拒绝的问题
|
||||
|
||||
---
|
||||
|
||||
## 三、SDK 直连权限管理
|
||||
|
||||
### 1. 管理页能力
|
||||
|
||||
- 新增 `/pb/manage/sdk-permission-manage`
|
||||
- 支持创建角色后按角色给 `tbl_auth_users` 用户授权
|
||||
- 支持逐 collection 配置 `list / view / create / update / delete`
|
||||
|
||||
### 2. 页面交互约束
|
||||
|
||||
- 页面不显示角色 ID,只显示角色名称
|
||||
- 勾选权限后立即保存
|
||||
- `public` 和 `custom` 规则不允许在页面里继续勾选改写
|
||||
- 支持集合级 `全选`
|
||||
- 当某集合下所有操作都不可编辑时,`全选` 自动禁用
|
||||
|
||||
---
|
||||
|
||||
## 四、OpenSpec 记录
|
||||
|
||||
本次新增的 OpenSpec 记录包括:
|
||||
|
||||
- `openspec/specs/attachment-backed-media/spec.md`
|
||||
- `openspec/specs/document-manage-console/spec.md`
|
||||
- `openspec/specs/sdk-collection-permissions/spec.md`
|
||||
- `openspec/changes/archive/2026-03-28-pocketbase-manage-media-and-sdk-permissions/`
|
||||
|
||||
---
|
||||
|
||||
## 归档日期
|
||||
|
||||
- 2026-03-23
|
||||
|
||||
## 归档范围
|
||||
|
||||
27
docs/api.md
27
docs/api.md
@@ -5,8 +5,8 @@
|
||||
本文档描述当前项目中**已经真实实现**并可直接调用的后端接口。
|
||||
当前接口统一特征如下:
|
||||
|
||||
- 基础路径(生产):`https://bai-api.blv-oa.com/api`
|
||||
- 基础路径(本地):`http://localhost:3000/api`
|
||||
- 基础路径(生产):`https://bai-api.blv-oa.com/pb/api`
|
||||
- 基础路径(本地):`http://localhost:8090/pb/api`
|
||||
- 响应格式:JSON
|
||||
- 业务响应结构统一为:`code`、`msg`、`data`
|
||||
- 当前公开接口统一使用 **POST** 方法
|
||||
@@ -134,7 +134,8 @@
|
||||
"users_name": "张三",
|
||||
"users_phone": "13800138000",
|
||||
"users_phone_masked": "138****8000",
|
||||
"users_picture": "https://example.com/avatar.png",
|
||||
"users_picture": "ATT-1743123456789-abc123",
|
||||
"users_picture_url": "https://bai-api.blv-oa.com/pb/api/files/pbc_xxx/recordId/avatar.png",
|
||||
"openid": "oAbCdEfGh123456789",
|
||||
"company_id": "C10001",
|
||||
"company": null,
|
||||
@@ -162,7 +163,10 @@
|
||||
{
|
||||
"users_name": "张三",
|
||||
"users_phone_code": "2b7d9f2e3c4a5b6d7e8f",
|
||||
"users_picture": "https://example.com/avatar.png"
|
||||
"users_picture": "ATT-1743123456789-abc123",
|
||||
"users_id_pic_a": "ATT-1743123456789-id-a",
|
||||
"users_id_pic_b": "ATT-1743123456789-id-b",
|
||||
"users_title_picture": "ATT-1743123456789-title"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -172,7 +176,10 @@
|
||||
|---|---|---|---|
|
||||
| `users_name` | string | 是 | 用户姓名 |
|
||||
| `users_phone_code` | string | 是 | 微信手机号获取凭证 code,后端将据此换取真实手机号 |
|
||||
| `users_picture` | string | 是 | 用户头像 URL |
|
||||
| `users_picture` | string | 是 | 用户头像附件的 `attachments_id` |
|
||||
| `users_id_pic_a` | string | 否 | 证件正面附件的 `attachments_id` |
|
||||
| `users_id_pic_b` | string | 否 | 证件反面附件的 `attachments_id` |
|
||||
| `users_title_picture` | string | 否 | 资质附件的 `attachments_id` |
|
||||
|
||||
### 处理逻辑
|
||||
|
||||
@@ -181,6 +188,7 @@
|
||||
- 不再从 body 读取 `users_wx_code`
|
||||
- 使用 `users_phone_code` 调微信官方接口换取真实手机号
|
||||
- 将真实手机号写入数据库字段 `users_phone`
|
||||
- `users_picture`、`users_id_pic_a`、`users_id_pic_b`、`users_title_picture` 均按 `attachments_id` 存储,服务端查询用户信息时会自动补充对应文件流链接
|
||||
- 若用户首次从“三项资料均为空”变为“三项资料均完整”,则将 `users_type` 从 `游客` 升级为 `注册用户`
|
||||
- 返回更新后的完整用户信息
|
||||
|
||||
@@ -198,7 +206,14 @@
|
||||
"users_name": "张三",
|
||||
"users_phone": "13800138000",
|
||||
"users_phone_masked": "138****8000",
|
||||
"users_picture": "https://example.com/avatar.png",
|
||||
"users_picture": "ATT-1743123456789-abc123",
|
||||
"users_picture_url": "https://bai-api.blv-oa.com/pb/api/files/pbc_xxx/recordId/avatar.png",
|
||||
"users_id_pic_a": "ATT-1743123456789-id-a",
|
||||
"users_id_pic_a_url": "https://bai-api.blv-oa.com/pb/api/files/pbc_xxx/recordId/id-a.png",
|
||||
"users_id_pic_b": "ATT-1743123456789-id-b",
|
||||
"users_id_pic_b_url": "https://bai-api.blv-oa.com/pb/api/files/pbc_xxx/recordId/id-b.png",
|
||||
"users_title_picture": "ATT-1743123456789-title",
|
||||
"users_title_picture_url": "https://bai-api.blv-oa.com/pb/api/files/pbc_xxx/recordId/title.png",
|
||||
"openid": "oAbCdEfGh123456789",
|
||||
"company_id": "",
|
||||
"company": null,
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
补充约定:
|
||||
|
||||
- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。
|
||||
- `document_image`、`document_video`、`document_file` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。
|
||||
- `document_type` 使用多选字符串持久化,但格式特殊:`system_dict_id@dict_word_enum|system_dict_id@dict_word_enum`;前端显示时用枚举值描述,存库时保留该组合值。
|
||||
- `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`。
|
||||
- `document_status` 仅允许 `有效`、`过期` 两种值,并由系统根据生效日期与到期日期自动计算;当两者都为空时默认 `有效`。
|
||||
- `document_owner` 的业务含义为“上传者openid”。
|
||||
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
|
||||
- `tbl_attachments` 的文件查看/下载权限应保持公开;真正的业务访问控制交由引用 `attachments_id` 的业务表和业务接口决定。
|
||||
- 文档字段中,面向用户填写的字段里只有 `document_title`、`document_type` 设为必填,其余字段均允许为空。
|
||||
|
||||
---
|
||||
@@ -55,6 +56,7 @@
|
||||
| document_content | text | 正文内容,保存 Markdown 原文 |
|
||||
| document_image | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
|
||||
| document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
|
||||
| document_file | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
|
||||
| document_owner | text | 上传者openid |
|
||||
| document_relation_model | text | 关联机型/模型标识 |
|
||||
| document_keywords | text | 关键词,多选后用 `|` 分隔保存 |
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
| `company_id` | `text` | 否 | 公司 ID |
|
||||
| `users_parent_id` | `text` | 否 | 上级用户 ID |
|
||||
| `users_promo_code` | `text` | 否 | 推广码 |
|
||||
| `users_id_pic_a` | `file` | 否 | 证件照正面,`maxSelect: 1`,允许 `jpeg/png/webp` |
|
||||
| `users_id_pic_b` | `file` | 否 | 证件照反面,`maxSelect: 1`,允许 `jpeg/png/webp` |
|
||||
| `users_title_picture` | `file` | 否 | 资质照片,`maxSelect: 1`,允许 `jpeg/png/webp` |
|
||||
| `users_picture` | `text` | 否 | 用户头像 |
|
||||
| `users_id_pic_a` | `text` | 否 | 保存 `tbl_attachments.attachments_id`,用于关联证件照正面 |
|
||||
| `users_id_pic_b` | `text` | 否 | 保存 `tbl_attachments.attachments_id`,用于关联证件照反面 |
|
||||
| `users_title_picture` | `text` | 否 | 保存 `tbl_attachments.attachments_id`,用于关联资质照片 |
|
||||
| `users_picture` | `text` | 否 | 用户头像,保存 `tbl_attachments.attachments_id` |
|
||||
| `usergroups_id` | `text` | 否 | 用户组 ID |
|
||||
|
||||
### 索引
|
||||
@@ -187,4 +187,4 @@
|
||||
|
||||
1. `tbl_auth_users` 是 `auth` 集合,除上表字段外,还会受 PocketBase auth 系统字段与认证配置影响。
|
||||
2. 当前文档仅以 `script/pocketbase.newpb.js` 为准,不代表线上数据库已经 100% 同步成功。
|
||||
3. 若你需要,我可以继续帮你再生成一份“更像数据库设计说明书”的版本,增加字段含义、业务用途、关联关系三列。
|
||||
3. 若你需要,我可以继续帮你再生成一份“更像数据库设计说明书”的版本,增加字段含义、业务用途、关联关系三列。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
@@ -0,0 +1,77 @@
|
||||
## Context
|
||||
|
||||
当前 PocketBase hooks 项目已经不再是单一的微信登录接口集合,而是同时承担管理端页面、附件上传、文档管理、字典管理和 PocketBase SDK 直连权限配置。最近一轮需求把“所有业务文件集中存入 `tbl_attachments`”和“管理用户可直接为角色配置 collection CRUD 权限”两个方向一起落地,涉及数据库字段、路由 body limit、返回模型、页面交互和线上 schema 变更。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 用 OpenSpec 为已完成能力建立正式归档,明确能力边界。
|
||||
- 记录“仅 `tbl_attachments` 保存真实文件,其他表只保存附件 ID”的统一模型。
|
||||
- 记录文档管理页的三类附件与保存体验要求。
|
||||
- 记录 SDK 权限页的角色驱动授权模型和 UI 约束。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不替代现有 OpenAPI 文档。
|
||||
- 不重新设计 PocketBase 的 `_superusers` 体系。
|
||||
- 不把所有复杂业务权限都迁移成 PocketBase 原生 rules 以外的自定义表达式语言。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 业务文件统一收敛到 `tbl_attachments`
|
||||
|
||||
除 `tbl_attachments.attachments_link` 外,不再保留其他业务 `file` 字段。文档、字典、用户资料等业务记录统一保存 `attachments_id`,读取时再由 hooks 反查附件元数据与文件流链接。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 避免多个业务表重复持有文件字段,降低 schema 演化成本。
|
||||
- 统一附件上传、回读、删除引用校验逻辑。
|
||||
- 让 PocketBase SDK 与 hooks API 在附件访问上共享同一套标识。
|
||||
|
||||
### 2. 多附件字段统一使用 `|` 分隔的文本存储
|
||||
|
||||
`document_image`、`document_video`、`document_file` 等多附件字段继续使用 `text`,并以 `|` 分隔多个 `attachments_id`。hooks service 负责序列化、反序列化和联查回读。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 与项目现有字典聚合字段设计风格一致。
|
||||
- 避免为每类附件单独引入子表或 relation migration。
|
||||
- 在 PocketBase JSVM hooks 环境里实现最简单直接。
|
||||
|
||||
### 3. 大文件上传继续走 hooks 自定义路由,但显式放宽 body limit
|
||||
|
||||
上传接口仍使用 `/pb/api/attachment/upload`,并通过 PocketBase custom route 的 `$apis.bodyLimit(...)` 放宽请求体限制。数据库字段 `attachments_link.maxSize` 已解除额外限制,上传链路重点落在自定义路由与网关配置协同。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 保持现有 `ManagePlatform` 鉴权、业务字段补充和返回结构不变。
|
||||
- 避免直接开放 `tbl_attachments.createRule` 给 SDK 原生写入。
|
||||
|
||||
### 4. SDK 直连权限按“角色 -> 集合 -> CRUD”建模
|
||||
|
||||
权限页以角色为中心展示每个 collection 的 `list/view/create/update/delete` 五类直连权限,并直接映射到 PocketBase collection rules。页面不显示角色 ID,只显示角色名称;勾选后立即保存;公开和 custom 规则不提供可编辑勾选框。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 用户理解成本比“直接编辑 rule 字符串”更低。
|
||||
- 保持 `ManagePlatform` 用户通过 hooks 页面管理,而不是要求他们成为 `_superusers`。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- `[PocketBase rule 冲突]` → 如果 collection 已经使用复杂 custom rule,权限页只做只读提示,不强行覆盖。
|
||||
- `[大文件上传依赖基础设施]` → 即使 hooks 已放宽 body limit,仍需同步保证 Nginx / 反代允许更大请求体。
|
||||
- `[文本分隔字段的一致性]` → 所有写入都必须经由 service 统一序列化,避免手工写入脏值。
|
||||
- `[附件公开读取]` → `tbl_attachments` 公开 list/view 使文件流可直接访问,因此真正的业务访问控制必须由保存附件 ID 的业务表承担。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 通过 schema 脚本和 `POCKETBASE_AUTH_TOKEN` 为 `tbl_document` 增加 `document_file` 字段。
|
||||
2. 部署更新后的 hooks 路由、service 与管理页面。
|
||||
3. 校验文档管理页三类附件上传、编辑与回显。
|
||||
4. 校验 SDK 权限页角色分配、即时保存和 collection rules 同步。
|
||||
5. 归档到 OpenSpec 与项目既有归档文档。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 暂无新的未决问题;当前能力范围已经按现有实现归档。
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
PocketBase hooks 项目最近连续完成了附件集中存储、文档管理增强、字典项图片、用户图片字段收敛、SDK 直连权限管理等一批跨模块能力,但仓库里还没有对应的 OpenSpec 记录。现在这些功能已经全部落地,需要把需求范围、能力边界和影响面正式归档,避免后续继续开发时只能依赖聊天记录回溯。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将业务表中的上传文件统一收敛为 `tbl_attachments.attachments_id` 引用,实际文件仅保存在 `tbl_attachments.attachments_link`。
|
||||
- 扩展 `tbl_document`,新增 `document_file`,并让 `document_image`、`document_video`、`document_file` 全部支持多附件、`|` 分隔存储与联表回读。
|
||||
- 增强 `manage/document-manage` 页面,补齐三类附件上传、拖拽上传、编辑态保持、局部状态提示、全屏图片预览和大文件上传体验。
|
||||
- 新增 `manage/sdk-permission-manage` 页面,用于按角色为 `tbl_auth_users` 用户分配 PocketBase SDK 直连 CRUD 权限,并把角色授权与 collection rules 管理收敛到 hooks 页面中。
|
||||
- 同步更新 OpenAPI、项目归档文档、PocketBase schema 脚本与线上 `document_file` 字段。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `attachment-backed-media`: 统一附件存储、附件 ID 引用、附件回读与公开下载策略。
|
||||
- `document-manage-console`: 文档管理页面的三类附件编辑、上传体验和保存反馈。
|
||||
- `sdk-collection-permissions`: 面向角色的 PocketBase SDK 直连权限分配与可视化管理。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- 受影响代码位于 `pocket-base/bai_api_pb_hooks/`、`pocket-base/bai_web_pb_hooks/`、`script/`、`docs/`、`pocket-base/spec/`。
|
||||
- 受影响系统包括 PocketBase schema、PocketBase hooks 页面、附件上传路由、文档管理 API、字典管理 API、用户资料返回结构与 SDK 直连权限页。
|
||||
- 线上已通过 `POCKETBASE_AUTH_TOKEN` 补齐 `tbl_document.document_file` 字段,后续部署需继续保持 hooks 与 schema 一致。
|
||||
@@ -0,0 +1,62 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Business records SHALL reference attachments by ID
|
||||
|
||||
The system SHALL store business media references as `tbl_attachments.attachments_id` values, and `tbl_attachments.attachments_link` SHALL remain the only business file field that stores actual uploaded file data.
|
||||
|
||||
#### Scenario: Document media is stored by attachment ID
|
||||
|
||||
- **WHEN** a document is created or updated with images, videos, or files
|
||||
- **THEN** the document record SHALL store attachment IDs in `document_image`, `document_video`, and `document_file` instead of raw file payloads
|
||||
|
||||
#### Scenario: Dictionary item images are stored by attachment ID
|
||||
|
||||
- **WHEN** a dictionary item image is uploaded from the dictionary management page
|
||||
- **THEN** the dictionary record SHALL persist the returned `attachments_id` in the aggregated dictionary image field
|
||||
|
||||
#### Scenario: User image fields are stored by attachment ID
|
||||
|
||||
- **WHEN** user profile-related image fields are written through hooks
|
||||
- **THEN** `users_picture`, `users_id_pic_a`, `users_id_pic_b`, and `users_title_picture` SHALL store attachment IDs only
|
||||
|
||||
### Requirement: Hooks SHALL resolve attachment metadata for referenced media
|
||||
|
||||
The system SHALL resolve attachment-backed fields to include file stream URLs and attachment metadata when hooks return documents, dictionaries, or user records.
|
||||
|
||||
#### Scenario: Document query returns attachment-backed links
|
||||
|
||||
- **WHEN** a document list or detail request is handled
|
||||
- **THEN** the response SHALL include attachment-backed URL and metadata fields for images, videos, and files derived from the stored attachment IDs
|
||||
|
||||
#### Scenario: Dictionary query returns item image links
|
||||
|
||||
- **WHEN** a dictionary list or detail request is handled
|
||||
- **THEN** each dictionary item with an image SHALL include the stored attachment ID, a resolved file URL, and the corresponding attachment metadata
|
||||
|
||||
#### Scenario: User query returns image URLs
|
||||
|
||||
- **WHEN** a user-facing hooks response contains attachment-backed image fields
|
||||
- **THEN** the response SHALL include resolved `..._url` and `..._attachment` fields for those image references
|
||||
|
||||
### Requirement: Attachment access SHALL separate read visibility from write control
|
||||
|
||||
The system SHALL allow public read access to attachment records and file streams at the PocketBase collection level, while write access SHALL continue to be controlled by hooks and existing `ManagePlatform` restrictions.
|
||||
|
||||
#### Scenario: Attachment records are publicly readable
|
||||
|
||||
- **WHEN** a client reads `tbl_attachments` through PocketBase list or view access
|
||||
- **THEN** the collection SHALL allow reading attachment metadata and downloading the linked file stream
|
||||
|
||||
#### Scenario: Hooks upload endpoint remains managed
|
||||
|
||||
- **WHEN** a client calls the hooks attachment management endpoints
|
||||
- **THEN** the existing `ManagePlatform` restrictions for hooks-controlled upload and management flows SHALL remain in effect
|
||||
|
||||
### Requirement: Attachment upload SHALL support large multipart requests
|
||||
|
||||
The hooks attachment upload route SHALL explicitly configure a larger PocketBase custom-route body limit so that large files can pass through the hooks layer without being rejected by the default request size limit.
|
||||
|
||||
#### Scenario: Large upload is accepted by custom route
|
||||
|
||||
- **WHEN** a client uploads a file larger than the default PocketBase custom-route body threshold to `/pb/api/attachment/upload`
|
||||
- **THEN** the route SHALL accept the multipart request as long as it remains within the configured hooks and infrastructure limits
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Document management SHALL support three attachment categories
|
||||
|
||||
The document management console SHALL provide separate upload and editing areas for image attachments, video attachments, and generic file attachments, and the three categories SHALL all persist attachment IDs to the document record.
|
||||
|
||||
#### Scenario: User uploads generic files to a document
|
||||
|
||||
- **WHEN** a management user adds files in the file attachment area and saves the document
|
||||
- **THEN** the saved document SHALL persist those attachment IDs in `document_file`
|
||||
|
||||
#### Scenario: Existing attachments are editable by category
|
||||
|
||||
- **WHEN** a management user opens a document in edit mode
|
||||
- **THEN** the page SHALL show existing images, videos, and files in their corresponding sections and allow removing them from the document
|
||||
|
||||
### Requirement: Document editor visibility and state SHALL follow editing intent
|
||||
|
||||
The document management page SHALL load with the document list only, keep the editor hidden until the user enters create or edit mode, and SHALL remain in the current editing context after upload or save operations.
|
||||
|
||||
#### Scenario: Page loads without editor
|
||||
|
||||
- **WHEN** the user first opens `/pb/manage/document-manage`
|
||||
- **THEN** the page SHALL show the document list and SHALL NOT show the editor section until create or edit mode is entered
|
||||
|
||||
#### Scenario: Save keeps current editing context
|
||||
|
||||
- **WHEN** the user successfully creates or updates a document
|
||||
- **THEN** the page SHALL stay on the current document editing context instead of clearing the form and forcing a return to blank create mode
|
||||
|
||||
#### Scenario: Upload does not reset editor mode
|
||||
|
||||
- **WHEN** the user uploads attachments while editing a document
|
||||
- **THEN** the page SHALL keep the current editor state and SHALL NOT implicitly switch modes
|
||||
|
||||
### Requirement: Document management SHALL provide immediate user feedback
|
||||
|
||||
The document management console SHALL show operation feedback both in the global page status area and near the save actions so that users receive confirmation or error context without scrolling.
|
||||
|
||||
#### Scenario: Local save feedback is visible
|
||||
|
||||
- **WHEN** a save or upload request succeeds or fails
|
||||
- **THEN** the page SHALL show the status message below the save controls in addition to the top-level message area
|
||||
|
||||
#### Scenario: Long-running requests show blocking feedback
|
||||
|
||||
- **WHEN** the page is waiting for a long-running upload, save, delete, or query response
|
||||
- **THEN** it SHALL show a full-screen loading mask so the user can tell the action is in progress
|
||||
|
||||
### Requirement: Document and dictionary images SHALL support full-screen preview
|
||||
|
||||
Image previews rendered in the management pages SHALL support opening the original image in a full-screen preview overlay.
|
||||
|
||||
#### Scenario: User previews an image
|
||||
|
||||
- **WHEN** the user clicks an image preview in document or dictionary management
|
||||
- **THEN** the page SHALL open a full-screen overlay showing the original image and SHALL allow closing it with explicit controls
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Role-based SDK direct access SHALL be configurable from hooks pages
|
||||
|
||||
The system SHALL provide a hooks-mounted management page that allows `ManagePlatform` users to assign roles to auth users and configure PocketBase SDK direct-access CRUD permissions by role and collection.
|
||||
|
||||
#### Scenario: Role is assigned to an auth user
|
||||
|
||||
- **WHEN** a management user selects a role for an auth user and saves the assignment
|
||||
- **THEN** the system SHALL persist the role mapping used by the SDK direct-access permission model
|
||||
|
||||
#### Scenario: Collection operation is granted by role
|
||||
|
||||
- **WHEN** a management user authorizes a role for a collection operation such as list, view, create, update, or delete
|
||||
- **THEN** the corresponding PocketBase collection access rule SHALL be updated for that role
|
||||
|
||||
### Requirement: Role identity SHALL be name-based in the management UI
|
||||
|
||||
The hooks permission page SHALL hide internal role IDs from the UI and present role assignments and selectors by role name only.
|
||||
|
||||
#### Scenario: Current role display hides role ID
|
||||
|
||||
- **WHEN** a management user views the role assignment page
|
||||
- **THEN** current role labels and selectors SHALL show the role name without exposing the internal role ID
|
||||
|
||||
### Requirement: Permission editing SHALL be immediate and constrained by rule type
|
||||
|
||||
The permission page SHALL save authorization changes immediately after a checkbox toggle, SHALL render CRUD labels in Chinese, and SHALL prevent editing for operations already governed by public or custom rules.
|
||||
|
||||
#### Scenario: Authorization change auto-saves
|
||||
|
||||
- **WHEN** a management user toggles an editable permission checkbox
|
||||
- **THEN** the change SHALL be persisted immediately without requiring a separate save button
|
||||
|
||||
#### Scenario: Public operation is read-only
|
||||
|
||||
- **WHEN** a collection operation is already publicly accessible
|
||||
- **THEN** the page SHALL show a brief `可公开访问` notice and SHALL NOT render an editable authorization checkbox for that operation
|
||||
|
||||
#### Scenario: Custom operation is read-only
|
||||
|
||||
- **WHEN** a collection operation is governed by a custom rule
|
||||
- **THEN** the page SHALL show `当前操作使用 custom 规则,禁止修改` and SHALL NOT render an editable authorization checkbox for that operation
|
||||
|
||||
### Requirement: Collection-level bulk selection SHALL respect editable operations only
|
||||
|
||||
The permission page SHALL provide a collection-level select-all control that applies only to editable operations, and the control SHALL be disabled when the collection has no editable operations.
|
||||
|
||||
#### Scenario: Collection select-all grants editable operations
|
||||
|
||||
- **WHEN** a management user enables the collection-level select-all control
|
||||
- **THEN** all editable CRUD operations for the current role on that collection SHALL be granted and auto-saved
|
||||
|
||||
#### Scenario: Collection select-all is disabled when nothing is editable
|
||||
|
||||
- **WHEN** every operation in a collection is either public, custom, or otherwise not editable
|
||||
- **THEN** the collection-level select-all control SHALL be disabled
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. Attachment-backed Media
|
||||
|
||||
- [x] 1.1 将非 `tbl_attachments` 的业务文件字段收敛为附件 ID 语义并同步 hooks 返回结构。
|
||||
- [x] 1.2 为 `tbl_document` 增加 `document_file` 字段,并让文档附件三类字段统一支持多值存储和联查回读。
|
||||
- [x] 1.3 放宽附件上传自定义路由 body limit,修复大文件上传链路。
|
||||
|
||||
## 2. Document Manage Console
|
||||
|
||||
- [x] 2.1 扩展文档管理页面,支持图片、视频、文件三块上传区域与拖拽上传。
|
||||
- [x] 2.2 调整文档管理页交互,使编辑区默认隐藏、保存后保持当前编辑态,并在按钮区域补充局部状态提示。
|
||||
- [x] 2.3 为文档与字典页面的图片预览增加全屏查看原图能力。
|
||||
|
||||
## 3. SDK Collection Permissions
|
||||
|
||||
- [x] 3.1 新增 SDK 权限管理页面,并支持角色名称视图、用户角色绑定与 collection CRUD 权限分配。
|
||||
- [x] 3.2 将权限页交互调整为即时保存,并对 public/custom 规则提供只读说明。
|
||||
- [x] 3.3 增加集合全选、中文文案与无横向滚动布局优化。
|
||||
|
||||
## 4. Documentation and Archive
|
||||
|
||||
- [x] 4.1 同步 OpenAPI、表结构说明和 README 中与附件、文档、权限页相关的行为说明。
|
||||
- [x] 4.2 使用 OpenSpec 记录本次变更并完成归档。
|
||||
2
openspec/changes/probe-change/.openspec.yaml
Normal file
2
openspec/changes/probe-change/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
65
openspec/specs/attachment-backed-media/spec.md
Normal file
65
openspec/specs/attachment-backed-media/spec.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# attachment-backed-media Specification
|
||||
|
||||
## Purpose
|
||||
定义 PocketBase hooks 项目中“真实文件只存于 `tbl_attachments`,业务表仅保存附件 ID,并由 hooks 回读文件流链接与元数据”的统一附件模型。
|
||||
## Requirements
|
||||
### Requirement: Business records SHALL reference attachments by ID
|
||||
|
||||
The system SHALL store business media references as `tbl_attachments.attachments_id` values, and `tbl_attachments.attachments_link` SHALL remain the only business file field that stores actual uploaded file data.
|
||||
|
||||
#### Scenario: Document media is stored by attachment ID
|
||||
|
||||
- **WHEN** a document is created or updated with images, videos, or files
|
||||
- **THEN** the document record SHALL store attachment IDs in `document_image`, `document_video`, and `document_file` instead of raw file payloads
|
||||
|
||||
#### Scenario: Dictionary item images are stored by attachment ID
|
||||
|
||||
- **WHEN** a dictionary item image is uploaded from the dictionary management page
|
||||
- **THEN** the dictionary record SHALL persist the returned `attachments_id` in the aggregated dictionary image field
|
||||
|
||||
#### Scenario: User image fields are stored by attachment ID
|
||||
|
||||
- **WHEN** user profile-related image fields are written through hooks
|
||||
- **THEN** `users_picture`, `users_id_pic_a`, `users_id_pic_b`, and `users_title_picture` SHALL store attachment IDs only
|
||||
|
||||
### Requirement: Hooks SHALL resolve attachment metadata for referenced media
|
||||
|
||||
The system SHALL resolve attachment-backed fields to include file stream URLs and attachment metadata when hooks return documents, dictionaries, or user records.
|
||||
|
||||
#### Scenario: Document query returns attachment-backed links
|
||||
|
||||
- **WHEN** a document list or detail request is handled
|
||||
- **THEN** the response SHALL include attachment-backed URL and metadata fields for images, videos, and files derived from the stored attachment IDs
|
||||
|
||||
#### Scenario: Dictionary query returns item image links
|
||||
|
||||
- **WHEN** a dictionary list or detail request is handled
|
||||
- **THEN** each dictionary item with an image SHALL include the stored attachment ID, a resolved file URL, and the corresponding attachment metadata
|
||||
|
||||
#### Scenario: User query returns image URLs
|
||||
|
||||
- **WHEN** a user-facing hooks response contains attachment-backed image fields
|
||||
- **THEN** the response SHALL include resolved `..._url` and `..._attachment` fields for those image references
|
||||
|
||||
### Requirement: Attachment access SHALL separate read visibility from write control
|
||||
|
||||
The system SHALL allow public read access to attachment records and file streams at the PocketBase collection level, while write access SHALL continue to be controlled by hooks and existing `ManagePlatform` restrictions.
|
||||
|
||||
#### Scenario: Attachment records are publicly readable
|
||||
|
||||
- **WHEN** a client reads `tbl_attachments` through PocketBase list or view access
|
||||
- **THEN** the collection SHALL allow reading attachment metadata and downloading the linked file stream
|
||||
|
||||
#### Scenario: Hooks upload endpoint remains managed
|
||||
|
||||
- **WHEN** a client calls the hooks attachment management endpoints
|
||||
- **THEN** the existing `ManagePlatform` restrictions for hooks-controlled upload and management flows SHALL remain in effect
|
||||
|
||||
### Requirement: Attachment upload SHALL support large multipart requests
|
||||
|
||||
The hooks attachment upload route SHALL explicitly configure a larger PocketBase custom-route body limit so that large files can pass through the hooks layer without being rejected by the default request size limit.
|
||||
|
||||
#### Scenario: Large upload is accepted by custom route
|
||||
|
||||
- **WHEN** a client uploads a file larger than the default PocketBase custom-route body threshold to `/pb/api/attachment/upload`
|
||||
- **THEN** the route SHALL accept the multipart request as long as it remains within the configured hooks and infrastructure limits
|
||||
60
openspec/specs/document-manage-console/spec.md
Normal file
60
openspec/specs/document-manage-console/spec.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# document-manage-console Specification
|
||||
|
||||
## Purpose
|
||||
定义 `/pb/manage/document-manage` 的编辑、上传、反馈与附件管理体验,确保文档管理页在图片、视频、文件三类附件下保持一致交互。
|
||||
## Requirements
|
||||
### Requirement: Document management SHALL support three attachment categories
|
||||
|
||||
The document management console SHALL provide separate upload and editing areas for image attachments, video attachments, and generic file attachments, and the three categories SHALL all persist attachment IDs to the document record.
|
||||
|
||||
#### Scenario: User uploads generic files to a document
|
||||
|
||||
- **WHEN** a management user adds files in the file attachment area and saves the document
|
||||
- **THEN** the saved document SHALL persist those attachment IDs in `document_file`
|
||||
|
||||
#### Scenario: Existing attachments are editable by category
|
||||
|
||||
- **WHEN** a management user opens a document in edit mode
|
||||
- **THEN** the page SHALL show existing images, videos, and files in their corresponding sections and allow removing them from the document
|
||||
|
||||
### Requirement: Document editor visibility and state SHALL follow editing intent
|
||||
|
||||
The document management page SHALL load with the document list only, keep the editor hidden until the user enters create or edit mode, and SHALL remain in the current editing context after upload or save operations.
|
||||
|
||||
#### Scenario: Page loads without editor
|
||||
|
||||
- **WHEN** the user first opens `/pb/manage/document-manage`
|
||||
- **THEN** the page SHALL show the document list and SHALL NOT show the editor section until create or edit mode is entered
|
||||
|
||||
#### Scenario: Save keeps current editing context
|
||||
|
||||
- **WHEN** the user successfully creates or updates a document
|
||||
- **THEN** the page SHALL stay on the current document editing context instead of clearing the form and forcing a return to blank create mode
|
||||
|
||||
#### Scenario: Upload does not reset editor mode
|
||||
|
||||
- **WHEN** the user uploads attachments while editing a document
|
||||
- **THEN** the page SHALL keep the current editor state and SHALL NOT implicitly switch modes
|
||||
|
||||
### Requirement: Document management SHALL provide immediate user feedback
|
||||
|
||||
The document management console SHALL show operation feedback both in the global page status area and near the save actions so that users receive confirmation or error context without scrolling.
|
||||
|
||||
#### Scenario: Local save feedback is visible
|
||||
|
||||
- **WHEN** a save or upload request succeeds or fails
|
||||
- **THEN** the page SHALL show the status message below the save controls in addition to the top-level message area
|
||||
|
||||
#### Scenario: Long-running requests show blocking feedback
|
||||
|
||||
- **WHEN** the page is waiting for a long-running upload, save, delete, or query response
|
||||
- **THEN** it SHALL show a full-screen loading mask so the user can tell the action is in progress
|
||||
|
||||
### Requirement: Document and dictionary images SHALL support full-screen preview
|
||||
|
||||
Image previews rendered in the management pages SHALL support opening the original image in a full-screen preview overlay.
|
||||
|
||||
#### Scenario: User previews an image
|
||||
|
||||
- **WHEN** the user clicks an image preview in document or dictionary management
|
||||
- **THEN** the page SHALL open a full-screen overlay showing the original image and SHALL allow closing it with explicit controls
|
||||
60
openspec/specs/sdk-collection-permissions/spec.md
Normal file
60
openspec/specs/sdk-collection-permissions/spec.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# sdk-collection-permissions Specification
|
||||
|
||||
## Purpose
|
||||
定义面向 `ManagePlatform` 用户的 SDK 直连权限配置能力,使角色绑定与 collection CRUD 授权能够通过 hooks 页面完成并映射到 PocketBase rules。
|
||||
## Requirements
|
||||
### Requirement: Role-based SDK direct access SHALL be configurable from hooks pages
|
||||
|
||||
The system SHALL provide a hooks-mounted management page that allows `ManagePlatform` users to assign roles to auth users and configure PocketBase SDK direct-access CRUD permissions by role and collection.
|
||||
|
||||
#### Scenario: Role is assigned to an auth user
|
||||
|
||||
- **WHEN** a management user selects a role for an auth user and saves the assignment
|
||||
- **THEN** the system SHALL persist the role mapping used by the SDK direct-access permission model
|
||||
|
||||
#### Scenario: Collection operation is granted by role
|
||||
|
||||
- **WHEN** a management user authorizes a role for a collection operation such as list, view, create, update, or delete
|
||||
- **THEN** the corresponding PocketBase collection access rule SHALL be updated for that role
|
||||
|
||||
### Requirement: Role identity SHALL be name-based in the management UI
|
||||
|
||||
The hooks permission page SHALL hide internal role IDs from the UI and present role assignments and selectors by role name only.
|
||||
|
||||
#### Scenario: Current role display hides role ID
|
||||
|
||||
- **WHEN** a management user views the role assignment page
|
||||
- **THEN** current role labels and selectors SHALL show the role name without exposing the internal role ID
|
||||
|
||||
### Requirement: Permission editing SHALL be immediate and constrained by rule type
|
||||
|
||||
The permission page SHALL save authorization changes immediately after a checkbox toggle, SHALL render CRUD labels in Chinese, and SHALL prevent editing for operations already governed by public or custom rules.
|
||||
|
||||
#### Scenario: Authorization change auto-saves
|
||||
|
||||
- **WHEN** a management user toggles an editable permission checkbox
|
||||
- **THEN** the change SHALL be persisted immediately without requiring a separate save button
|
||||
|
||||
#### Scenario: Public operation is read-only
|
||||
|
||||
- **WHEN** a collection operation is already publicly accessible
|
||||
- **THEN** the page SHALL show a brief `可公开访问` notice and SHALL NOT render an editable authorization checkbox for that operation
|
||||
|
||||
#### Scenario: Custom operation is read-only
|
||||
|
||||
- **WHEN** a collection operation is governed by a custom rule
|
||||
- **THEN** the page SHALL show `当前操作使用 custom 规则,禁止修改` and SHALL NOT render an editable authorization checkbox for that operation
|
||||
|
||||
### Requirement: Collection-level bulk selection SHALL respect editable operations only
|
||||
|
||||
The permission page SHALL provide a collection-level select-all control that applies only to editable operations, and the control SHALL be disabled when the collection has no editable operations.
|
||||
|
||||
#### Scenario: Collection select-all grants editable operations
|
||||
|
||||
- **WHEN** a management user enables the collection-level select-all control
|
||||
- **THEN** all editable CRUD operations for the current role on that collection SHALL be granted and auto-saved
|
||||
|
||||
#### Scenario: Collection select-all is disabled when nothing is editable
|
||||
|
||||
- **WHEN** every operation in a collection is either public, custom, or otherwise not editable
|
||||
- **THEN** the collection-level select-all control SHALL be disabled
|
||||
@@ -156,6 +156,10 @@ PocketBase JSVM 不是 Node.js 运行时:
|
||||
|
||||
- `pocket-base/spec/openapi.yaml`
|
||||
- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md`
|
||||
- `openspec/specs/attachment-backed-media/spec.md`
|
||||
- `openspec/specs/document-manage-console/spec.md`
|
||||
- `openspec/specs/sdk-collection-permissions/spec.md`
|
||||
- `openspec/changes/archive/2026-03-28-pocketbase-manage-media-and-sdk-permissions/`
|
||||
|
||||
本次变更重点包括:
|
||||
|
||||
@@ -167,6 +171,10 @@ PocketBase JSVM 不是 Node.js 运行时:
|
||||
- `users_phone` 索引由唯一改为普通索引
|
||||
- `tbl_auth_users` 以全平台统一 `openid` 为业务身份锚点
|
||||
- auth 集合兼容占位 `email`、随机密码与 `passwordConfirm`
|
||||
- 业务文件统一收敛到 `tbl_attachments`
|
||||
- `tbl_document` 新增 `document_file`
|
||||
- 文档管理页支持图片 / 视频 / 文件三类附件
|
||||
- SDK 直连权限页支持按角色配置 collection CRUD 权限
|
||||
|
||||
## 与原项目关系
|
||||
|
||||
|
||||
@@ -38,5 +38,11 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/create.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/update.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/delete.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document-history/list.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/context.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/role-save.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/role-delete.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/user-role-update.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/collection-save.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/manageplatform-sync.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/login.js`)
|
||||
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`)
|
||||
|
||||
@@ -2,3 +2,4 @@ require(`${__hooks}/bai_web_pb_hooks/pages/index.js`)
|
||||
require(`${__hooks}/bai_web_pb_hooks/pages/login.js`)
|
||||
require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`)
|
||||
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)
|
||||
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)
|
||||
|
||||
@@ -25,4 +25,4 @@ routerAdd('POST', '/api/attachment/upload', function (e) {
|
||||
data: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
}, $apis.bodyLimit(536870912))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
routerAdd('POST', '/api/sdk-permission/collection-save', function (e) {
|
||||
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
|
||||
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
guards.requireJson(e)
|
||||
guards.requireManagePlatformUser(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const payload = guards.validateSdkPermissionCollectionSaveBody(e)
|
||||
const data = permissionService.saveCollectionRules(payload)
|
||||
|
||||
return success(e, '保存集合权限成功', data)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
routerAdd('POST', '/api/sdk-permission/context', function (e) {
|
||||
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
|
||||
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
guards.requireJson(e)
|
||||
guards.requireManagePlatformUser(e)
|
||||
|
||||
const payload = guards.validateSdkPermissionContextBody(e)
|
||||
const data = permissionService.getManagementContext(payload.keyword)
|
||||
|
||||
return success(e, '查询权限管理上下文成功', data)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
routerAdd('POST', '/api/sdk-permission/manageplatform-sync', function (e) {
|
||||
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
|
||||
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
guards.requireJson(e)
|
||||
guards.requireManagePlatformUser(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const data = permissionService.syncManagePlatformFullAccess()
|
||||
|
||||
return success(e, '已同步 ManagePlatform 业务全权限', data)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
routerAdd('POST', '/api/sdk-permission/role-delete', function (e) {
|
||||
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
|
||||
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
guards.requireJson(e)
|
||||
guards.requireManagePlatformUser(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const payload = guards.validateSdkPermissionRoleDeleteBody(e)
|
||||
const data = permissionService.deleteRole(payload.role_id)
|
||||
|
||||
return success(e, '删除角色成功', data)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
routerAdd('POST', '/api/sdk-permission/role-save', function (e) {
|
||||
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
|
||||
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
guards.requireJson(e)
|
||||
guards.requireManagePlatformUser(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const payload = guards.validateSdkPermissionRoleBody(e)
|
||||
const data = permissionService.saveRole(payload)
|
||||
|
||||
return success(e, '保存角色成功', data)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
routerAdd('POST', '/api/sdk-permission/user-role-update', function (e) {
|
||||
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
|
||||
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
|
||||
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
|
||||
|
||||
guards.requireJson(e)
|
||||
guards.requireManagePlatformUser(e)
|
||||
guards.duplicateGuard(e)
|
||||
|
||||
const payload = guards.validateSdkPermissionUserRoleBody(e)
|
||||
const data = permissionService.updateUserRole(payload)
|
||||
|
||||
return success(e, '更新用户角色成功', data)
|
||||
})
|
||||
@@ -28,7 +28,14 @@ function validateProfileBody(e) {
|
||||
if (!payload.users_name) throw createAppError(400, 'users_name 为必填项')
|
||||
if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项')
|
||||
if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项')
|
||||
return payload
|
||||
return {
|
||||
users_name: payload.users_name,
|
||||
users_phone_code: payload.users_phone_code,
|
||||
users_picture: payload.users_picture,
|
||||
users_id_pic_a: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_a') ? payload.users_id_pic_a : undefined,
|
||||
users_id_pic_b: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_b') ? payload.users_id_pic_b : undefined,
|
||||
users_title_picture: Object.prototype.hasOwnProperty.call(payload, 'users_title_picture') ? payload.users_title_picture : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function validatePlatformRegisterBody(e) {
|
||||
@@ -44,7 +51,24 @@ function validatePlatformRegisterBody(e) {
|
||||
throw createAppError(400, 'password 与 passwordConfirm 不一致')
|
||||
}
|
||||
|
||||
return payload
|
||||
return {
|
||||
users_name: payload.users_name,
|
||||
users_phone: payload.users_phone,
|
||||
password: payload.password,
|
||||
passwordConfirm: payload.passwordConfirm,
|
||||
users_picture: payload.users_picture,
|
||||
users_id_number: payload.users_id_number || '',
|
||||
users_level: payload.users_level || '',
|
||||
users_type: payload.users_type || '',
|
||||
company_id: payload.company_id || '',
|
||||
users_parent_id: payload.users_parent_id || '',
|
||||
users_promo_code: payload.users_promo_code || '',
|
||||
usergroups_id: payload.usergroups_id || '',
|
||||
email: payload.email || '',
|
||||
users_id_pic_a: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_a') ? payload.users_id_pic_a : undefined,
|
||||
users_id_pic_b: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_b') ? payload.users_id_pic_b : undefined,
|
||||
users_title_picture: Object.prototype.hasOwnProperty.call(payload, 'users_title_picture') ? payload.users_title_picture : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function validatePlatformLoginBody(e) {
|
||||
@@ -193,7 +217,21 @@ function validateAttachmentDeleteBody(e) {
|
||||
}
|
||||
|
||||
function validateAttachmentUploadBody(e) {
|
||||
const payload = sanitizePayload(e.requestInfo().body || {})
|
||||
const request = e.request
|
||||
if (request && typeof request.parseMultipartForm === 'function') {
|
||||
// Explicit multipart parsing avoids the default Request.ParseForm 10MB cap path.
|
||||
request.parseMultipartForm(64 * 1024 * 1024)
|
||||
}
|
||||
|
||||
const payload = sanitizePayload({
|
||||
attachments_filename: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_filename') : '',
|
||||
attachments_filetype: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_filetype') : '',
|
||||
attachments_size: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_size') : 0,
|
||||
attachments_md5: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_md5') : '',
|
||||
attachments_ocr: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_ocr') : '',
|
||||
attachments_status: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_status') : '',
|
||||
attachments_remark: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_remark') : '',
|
||||
})
|
||||
|
||||
return {
|
||||
attachments_filename: payload.attachments_filename || '',
|
||||
@@ -282,6 +320,7 @@ function validateDocumentMutationBody(e, isUpdate) {
|
||||
document_content: payload.document_content || '',
|
||||
document_image: normalizeAttachmentIdList(payload.document_image, 'document_image'),
|
||||
document_video: normalizeAttachmentIdList(payload.document_video, 'document_video'),
|
||||
document_file: normalizeAttachmentIdList(payload.document_file, 'document_file'),
|
||||
document_relation_model: payload.document_relation_model || '',
|
||||
document_keywords: payload.document_keywords || '',
|
||||
document_share_count: typeof payload.document_share_count === 'undefined' ? '' : payload.document_share_count,
|
||||
@@ -311,6 +350,92 @@ function validateDocumentHistoryListBody(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuleConfig(input) {
|
||||
const current = sanitizePayload(input || {})
|
||||
const mode = String(current.mode || 'locked')
|
||||
const rawExpression = String(current.rawExpression || '')
|
||||
const roles = Array.isArray(current.roles)
|
||||
? current.roles.map(function (item) {
|
||||
return String(item || '').trim()
|
||||
}).filter(function (item) {
|
||||
return !!item
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
mode: mode,
|
||||
includeManagePlatform: !!current.includeManagePlatform,
|
||||
roles: roles,
|
||||
rawExpression: rawExpression,
|
||||
}
|
||||
}
|
||||
|
||||
function validateSdkPermissionContextBody(e) {
|
||||
const payload = parseBody(e)
|
||||
return {
|
||||
keyword: payload.keyword || '',
|
||||
}
|
||||
}
|
||||
|
||||
function validateSdkPermissionRoleBody(e) {
|
||||
const payload = parseBody(e)
|
||||
if (!payload.role_name) {
|
||||
throw createAppError(400, 'role_name 为必填项')
|
||||
}
|
||||
|
||||
return {
|
||||
original_role_id: payload.original_role_id || '',
|
||||
role_id: payload.role_id || '',
|
||||
role_name: payload.role_name,
|
||||
role_code: payload.role_code || '',
|
||||
role_status: typeof payload.role_status === 'undefined' ? 1 : payload.role_status,
|
||||
role_remark: payload.role_remark || '',
|
||||
}
|
||||
}
|
||||
|
||||
function validateSdkPermissionRoleDeleteBody(e) {
|
||||
const payload = parseBody(e)
|
||||
if (!payload.role_id) {
|
||||
throw createAppError(400, 'role_id 为必填项')
|
||||
}
|
||||
|
||||
return {
|
||||
role_id: payload.role_id,
|
||||
}
|
||||
}
|
||||
|
||||
function validateSdkPermissionUserRoleBody(e) {
|
||||
const payload = parseBody(e)
|
||||
if (!payload.pb_id) {
|
||||
throw createAppError(400, 'pb_id 为必填项')
|
||||
}
|
||||
|
||||
return {
|
||||
pb_id: payload.pb_id,
|
||||
usergroups_id: payload.usergroups_id || '',
|
||||
}
|
||||
}
|
||||
|
||||
function validateSdkPermissionCollectionSaveBody(e) {
|
||||
const payload = parseBody(e)
|
||||
if (!payload.collection_name) {
|
||||
throw createAppError(400, 'collection_name 为必填项')
|
||||
}
|
||||
|
||||
const rules = payload.rules && typeof payload.rules === 'object' ? payload.rules : {}
|
||||
|
||||
return {
|
||||
collection_name: payload.collection_name,
|
||||
rules: {
|
||||
list: normalizeRuleConfig(rules.list),
|
||||
view: normalizeRuleConfig(rules.view),
|
||||
create: normalizeRuleConfig(rules.create),
|
||||
update: normalizeRuleConfig(rules.update),
|
||||
delete: normalizeRuleConfig(rules.delete),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function requireAuthOpenid(e) {
|
||||
if (!e.auth) {
|
||||
throw createAppError(401, '认证令牌无效或已过期')
|
||||
@@ -389,6 +514,11 @@ module.exports = {
|
||||
validateDocumentMutationBody,
|
||||
validateDocumentDeleteBody,
|
||||
validateDocumentHistoryListBody,
|
||||
validateSdkPermissionContextBody,
|
||||
validateSdkPermissionRoleBody,
|
||||
validateSdkPermissionRoleDeleteBody,
|
||||
validateSdkPermissionUserRoleBody,
|
||||
validateSdkPermissionCollectionSaveBody,
|
||||
requireAuthOpenid,
|
||||
requireAuthUser,
|
||||
duplicateGuard,
|
||||
|
||||
@@ -26,7 +26,7 @@ function buildFileUrl(collectionName, recordId, filename, download) {
|
||||
|
||||
function normalizeDateValue(value) {
|
||||
const text = String(value || '').replace(/^\s+|\s+$/g, '')
|
||||
if (!text) return ''
|
||||
if (!text) return null
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
||||
return text + ' 00:00:00.000Z'
|
||||
@@ -198,8 +198,10 @@ function exportDocumentRecord(record) {
|
||||
const documentStatus = ensureDocumentStatus(record)
|
||||
const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
|
||||
const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
|
||||
const fileAttachmentList = resolveAttachmentList(record.getString('document_file'))
|
||||
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
|
||||
const firstVideoAttachment = videoAttachmentList.attachments.length ? videoAttachmentList.attachments[0] : null
|
||||
const firstFileAttachment = fileAttachmentList.attachments.length ? fileAttachmentList.attachments[0] : null
|
||||
|
||||
return {
|
||||
pb_id: record.id,
|
||||
@@ -223,6 +225,12 @@ function exportDocumentRecord(record) {
|
||||
document_video_attachments: videoAttachmentList.attachments,
|
||||
document_video_url: firstVideoAttachment ? firstVideoAttachment.attachments_url : '',
|
||||
document_video_attachment: firstVideoAttachment,
|
||||
document_file: fileAttachmentList.ids.join('|'),
|
||||
document_file_ids: fileAttachmentList.ids,
|
||||
document_file_urls: fileAttachmentList.urls,
|
||||
document_file_attachments: fileAttachmentList.attachments,
|
||||
document_file_url: firstFileAttachment ? firstFileAttachment.attachments_url : '',
|
||||
document_file_attachment: firstFileAttachment,
|
||||
document_owner: record.getString('document_owner'),
|
||||
document_relation_model: record.getString('document_relation_model'),
|
||||
document_keywords: record.getString('document_keywords'),
|
||||
@@ -265,6 +273,37 @@ function exportHistoryRecord(record) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorDetails(err, fallbackMessage) {
|
||||
return {
|
||||
statusCode: (err && err.statusCode) || (err && err.status) || 400,
|
||||
message: (err && err.message) || fallbackMessage || '操作失败',
|
||||
data: (err && err.data) || {},
|
||||
}
|
||||
}
|
||||
|
||||
function runInTransactionSafely(actionName, transactionHandler) {
|
||||
let result = null
|
||||
let capturedFailure = null
|
||||
|
||||
try {
|
||||
$app.runInTransaction(function (txApp) {
|
||||
try {
|
||||
result = transactionHandler(txApp)
|
||||
} catch (err) {
|
||||
capturedFailure = extractErrorDetails(err, actionName + '失败')
|
||||
throw new Error(capturedFailure.message)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (capturedFailure) {
|
||||
throw createAppError(capturedFailure.statusCode, capturedFailure.message, capturedFailure.data)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function createHistoryRecord(txApp, payload) {
|
||||
const collection = txApp.findCollectionByNameOrId('tbl_document_operation_history')
|
||||
const record = new Record(collection)
|
||||
@@ -368,7 +407,9 @@ function deleteAttachment(attachmentId) {
|
||||
const imageIds = parseAttachmentIdList(current.getString('document_image'))
|
||||
const videoIds = parseAttachmentIdList(current.getString('document_video'))
|
||||
|
||||
if (imageIds.indexOf(attachmentId) !== -1 || videoIds.indexOf(attachmentId) !== -1) {
|
||||
const fileIds = parseAttachmentIdList(current.getString('document_file'))
|
||||
|
||||
if (imageIds.indexOf(attachmentId) !== -1 || videoIds.indexOf(attachmentId) !== -1 || fileIds.indexOf(attachmentId) !== -1) {
|
||||
throw createAppError(400, '附件已被文档引用,无法删除')
|
||||
}
|
||||
}
|
||||
@@ -433,6 +474,7 @@ function getDocumentDetail(documentId) {
|
||||
function createDocument(userOpenid, payload) {
|
||||
ensureAttachmentIdsExist(payload.document_image, 'document_image')
|
||||
ensureAttachmentIdsExist(payload.document_video, 'document_video')
|
||||
ensureAttachmentIdsExist(payload.document_file, 'document_file')
|
||||
|
||||
const targetDocumentId = payload.document_id || buildBusinessId('DOC')
|
||||
const duplicated = findDocumentRecordByDocumentId(targetDocumentId)
|
||||
@@ -440,7 +482,7 @@ function createDocument(userOpenid, payload) {
|
||||
throw createAppError(400, 'document_id 已存在')
|
||||
}
|
||||
|
||||
return $app.runInTransaction(function (txApp) {
|
||||
return runInTransactionSafely('创建文档', function (txApp) {
|
||||
const collection = txApp.findCollectionByNameOrId('tbl_document')
|
||||
const record = new Record(collection)
|
||||
const effectDateValue = normalizeDateValue(payload.document_effect_date)
|
||||
@@ -457,12 +499,13 @@ function createDocument(userOpenid, payload) {
|
||||
record.set('document_content', payload.document_content || '')
|
||||
record.set('document_image', serializeAttachmentIdList(payload.document_image))
|
||||
record.set('document_video', serializeAttachmentIdList(payload.document_video))
|
||||
record.set('document_file', serializeAttachmentIdList(payload.document_file))
|
||||
record.set('document_owner', userOpenid || '')
|
||||
record.set('document_relation_model', payload.document_relation_model || '')
|
||||
record.set('document_keywords', payload.document_keywords || '')
|
||||
record.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
|
||||
record.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count'))
|
||||
record.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
|
||||
record.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count'))
|
||||
record.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count'))
|
||||
record.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count'))
|
||||
record.set('document_status', documentStatus)
|
||||
record.set('document_embedding_status', payload.document_embedding_status || '')
|
||||
record.set('document_embedding_error', payload.document_embedding_error || '')
|
||||
@@ -473,7 +516,14 @@ function createDocument(userOpenid, payload) {
|
||||
record.set('document_hotel_type', payload.document_hotel_type || '')
|
||||
record.set('document_remark', payload.document_remark || '')
|
||||
|
||||
txApp.save(record)
|
||||
try {
|
||||
txApp.save(record)
|
||||
} catch (err) {
|
||||
throw createAppError(400, '保存文档失败', {
|
||||
originalMessage: (err && err.message) || '未知错误',
|
||||
originalData: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
|
||||
createHistoryRecord(txApp, {
|
||||
documentId: record.getString('document_id'),
|
||||
@@ -495,8 +545,9 @@ function updateDocument(userOpenid, payload) {
|
||||
|
||||
ensureAttachmentIdsExist(payload.document_image, 'document_image')
|
||||
ensureAttachmentIdsExist(payload.document_video, 'document_video')
|
||||
ensureAttachmentIdsExist(payload.document_file, 'document_file')
|
||||
|
||||
return $app.runInTransaction(function (txApp) {
|
||||
return runInTransactionSafely('更新文档', function (txApp) {
|
||||
const target = txApp.findRecordById('tbl_document', record.id)
|
||||
const effectDateValue = normalizeDateValue(payload.document_effect_date)
|
||||
const expiryDateValue = normalizeDateValue(payload.document_expiry_date)
|
||||
@@ -511,11 +562,12 @@ function updateDocument(userOpenid, payload) {
|
||||
target.set('document_content', payload.document_content || '')
|
||||
target.set('document_image', serializeAttachmentIdList(payload.document_image))
|
||||
target.set('document_video', serializeAttachmentIdList(payload.document_video))
|
||||
target.set('document_file', serializeAttachmentIdList(payload.document_file))
|
||||
target.set('document_relation_model', payload.document_relation_model || '')
|
||||
target.set('document_keywords', payload.document_keywords || '')
|
||||
target.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
|
||||
target.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count'))
|
||||
target.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
|
||||
target.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count'))
|
||||
target.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count'))
|
||||
target.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count'))
|
||||
target.set('document_status', documentStatus)
|
||||
target.set('document_embedding_status', payload.document_embedding_status || '')
|
||||
target.set('document_embedding_error', payload.document_embedding_error || '')
|
||||
@@ -526,7 +578,14 @@ function updateDocument(userOpenid, payload) {
|
||||
target.set('document_hotel_type', payload.document_hotel_type || '')
|
||||
target.set('document_remark', payload.document_remark || '')
|
||||
|
||||
txApp.save(target)
|
||||
try {
|
||||
txApp.save(target)
|
||||
} catch (err) {
|
||||
throw createAppError(400, '保存文档失败', {
|
||||
originalMessage: (err && err.message) || '未知错误',
|
||||
originalData: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
|
||||
createHistoryRecord(txApp, {
|
||||
documentId: target.getString('document_id'),
|
||||
@@ -546,7 +605,7 @@ function deleteDocument(userOpenid, documentId) {
|
||||
throw createAppError(404, '未找到待删除的文档')
|
||||
}
|
||||
|
||||
return $app.runInTransaction(function (txApp) {
|
||||
return runInTransactionSafely('删除文档', function (txApp) {
|
||||
createHistoryRecord(txApp, {
|
||||
documentId: documentId,
|
||||
operationType: 'delete',
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
|
||||
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
|
||||
|
||||
const ROLE_COLLECTION = 'tbl_auth_roles'
|
||||
const USER_COLLECTION = 'tbl_auth_users'
|
||||
const MANAGE_PLATFORM_EXPR = '@request.auth.users_idtype = "ManagePlatform"'
|
||||
const AUTHENTICATED_EXPR = '@request.auth.id != ""'
|
||||
const RULE_KEYS = ['listRule', 'viewRule', 'createRule', 'updateRule', 'deleteRule']
|
||||
|
||||
function parseJsonResponse(response, actionName) {
|
||||
if (!response) {
|
||||
throw createAppError(500, actionName + ' 失败:PocketBase 响应为空')
|
||||
}
|
||||
|
||||
if (response.json && typeof response.json === 'object') {
|
||||
return response.json
|
||||
}
|
||||
|
||||
if (typeof response.body === 'string' && response.body) {
|
||||
return JSON.parse(response.body)
|
||||
}
|
||||
|
||||
if (response.body && typeof response.body === 'object') {
|
||||
return response.body
|
||||
}
|
||||
|
||||
if (typeof response.data === 'string' && response.data) {
|
||||
return JSON.parse(response.data)
|
||||
}
|
||||
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
return response.data
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getPocketBaseApiBaseUrl() {
|
||||
const base = String(env.pocketbaseApiUrl || '').replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
throw createAppError(500, '缺少 POCKETBASE_API_URL 配置,无法同步 PocketBase collection rules')
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function getPocketBaseAuthToken() {
|
||||
const token = String(env.pocketbaseAuthToken || '').trim()
|
||||
if (!token) {
|
||||
throw createAppError(500, '缺少 POCKETBASE_AUTH_TOKEN 配置,无法同步 PocketBase collection rules')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
function requestPocketBase(method, path, body, actionName) {
|
||||
const baseUrl = getPocketBaseApiBaseUrl()
|
||||
const token = getPocketBaseAuthToken()
|
||||
const headers = {
|
||||
Authorization: 'Bearer ' + token,
|
||||
}
|
||||
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const response = $http.send({
|
||||
url: baseUrl + path,
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
})
|
||||
|
||||
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw createAppError(500, actionName + ' 失败', {
|
||||
statusCode: response ? response.statusCode : 0,
|
||||
body: response ? String(response.body || '') : '',
|
||||
})
|
||||
}
|
||||
|
||||
return parseJsonResponse(response, actionName)
|
||||
}
|
||||
|
||||
function buildRoleId() {
|
||||
return 'ROLE-' + new Date().getTime() + '-' + $security.randomString(6)
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/^\s+|\s+$/g, '')
|
||||
}
|
||||
|
||||
function uniqueList(values) {
|
||||
const result = []
|
||||
for (let i = 0; i < (values || []).length; i += 1) {
|
||||
const current = normalizeText(values[i])
|
||||
if (current && result.indexOf(current) === -1) {
|
||||
result.push(current)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function listRoles() {
|
||||
const records = $app.findRecordsByFilter(ROLE_COLLECTION, '', 'role_name', 500, 0)
|
||||
return records.map(function (record) {
|
||||
return {
|
||||
pb_id: record.id,
|
||||
role_id: record.getString('role_id'),
|
||||
role_name: record.getString('role_name'),
|
||||
role_code: record.getString('role_code'),
|
||||
role_status: record.get('role_status'),
|
||||
role_remark: record.getString('role_remark'),
|
||||
created: String(record.created || ''),
|
||||
updated: String(record.updated || ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildRoleMap(roles) {
|
||||
const map = {}
|
||||
for (let i = 0; i < (roles || []).length; i += 1) {
|
||||
const role = roles[i]
|
||||
if (role && role.role_id) {
|
||||
map[role.role_id] = role
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function findRoleRecordByRoleId(roleId) {
|
||||
const value = normalizeText(roleId)
|
||||
if (!value) return null
|
||||
|
||||
const records = $app.findRecordsByFilter(ROLE_COLLECTION, 'role_id = {:roleId}', '', 1, 0, {
|
||||
roleId: value,
|
||||
})
|
||||
return records.length ? records[0] : null
|
||||
}
|
||||
|
||||
function saveRole(payload) {
|
||||
const roleId = normalizeText(payload.role_id) || buildRoleId()
|
||||
const originalRoleId = normalizeText(payload.original_role_id || roleId)
|
||||
const roleName = normalizeText(payload.role_name)
|
||||
|
||||
if (!roleName) {
|
||||
throw createAppError(400, 'role_name 为必填项')
|
||||
}
|
||||
|
||||
let record = findRoleRecordByRoleId(originalRoleId)
|
||||
if (!record) {
|
||||
const collection = $app.findCollectionByNameOrId(ROLE_COLLECTION)
|
||||
record = new Record(collection)
|
||||
record.set('role_id', roleId)
|
||||
}
|
||||
|
||||
const sameIdRecord = findRoleRecordByRoleId(roleId)
|
||||
if (sameIdRecord && sameIdRecord.id !== record.id) {
|
||||
throw createAppError(400, 'role_id 已存在')
|
||||
}
|
||||
|
||||
record.set('role_id', roleId)
|
||||
record.set('role_name', roleName)
|
||||
record.set('role_code', normalizeText(payload.role_code))
|
||||
record.set('role_status', Number(payload.role_status || 1))
|
||||
record.set('role_remark', normalizeText(payload.role_remark))
|
||||
|
||||
try {
|
||||
$app.save(record)
|
||||
} catch (err) {
|
||||
throw createAppError(400, '保存角色失败', {
|
||||
originalMessage: (err && err.message) || '未知错误',
|
||||
originalData: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
role: {
|
||||
pb_id: record.id,
|
||||
role_id: record.getString('role_id'),
|
||||
role_name: record.getString('role_name'),
|
||||
role_code: record.getString('role_code'),
|
||||
role_status: record.get('role_status'),
|
||||
role_remark: record.getString('role_remark'),
|
||||
created: String(record.created || ''),
|
||||
updated: String(record.updated || ''),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuleParts(config) {
|
||||
const parts = []
|
||||
if (config.includeManagePlatform) {
|
||||
parts.push(MANAGE_PLATFORM_EXPR)
|
||||
}
|
||||
if (config.mode === 'authenticated') {
|
||||
parts.push(AUTHENTICATED_EXPR)
|
||||
}
|
||||
if (config.mode === 'roleBased') {
|
||||
const roles = uniqueList(config.roles || [])
|
||||
for (let i = 0; i < roles.length; i += 1) {
|
||||
parts.push('@request.auth.usergroups_id = "' + roles[i].replace(/"/g, '\\"') + '"')
|
||||
}
|
||||
}
|
||||
return uniqueList(parts)
|
||||
}
|
||||
|
||||
function buildRuleExpression(config) {
|
||||
const mode = normalizeText(config.mode)
|
||||
|
||||
if (mode === 'public') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (mode === 'custom') {
|
||||
const raw = normalizeText(config.rawExpression)
|
||||
if (!raw) {
|
||||
return config.includeManagePlatform ? MANAGE_PLATFORM_EXPR : null
|
||||
}
|
||||
if (config.includeManagePlatform && raw.indexOf(MANAGE_PLATFORM_EXPR) === -1) {
|
||||
return '(' + raw + ') || ' + MANAGE_PLATFORM_EXPR
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
if (mode === 'locked') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts = buildRuleParts(config)
|
||||
return parts.length ? parts.join(' || ') : null
|
||||
}
|
||||
|
||||
function parseRuleExpression(ruleValue) {
|
||||
if (typeof ruleValue === 'undefined' || ruleValue === null) {
|
||||
return {
|
||||
mode: 'locked',
|
||||
includeManagePlatform: false,
|
||||
roles: [],
|
||||
rawExpression: '',
|
||||
}
|
||||
}
|
||||
|
||||
const expression = String(ruleValue)
|
||||
if (expression === '') {
|
||||
return {
|
||||
mode: 'public',
|
||||
includeManagePlatform: false,
|
||||
roles: [],
|
||||
rawExpression: '',
|
||||
}
|
||||
}
|
||||
|
||||
const includeManagePlatform = expression.indexOf(MANAGE_PLATFORM_EXPR) !== -1
|
||||
const includeAuthenticated = expression.indexOf(AUTHENTICATED_EXPR) !== -1
|
||||
const roles = []
|
||||
const roleRegex = /@request\.auth\.usergroups_id\s*=\s*"([^"]+)"/g
|
||||
let match = roleRegex.exec(expression)
|
||||
while (match) {
|
||||
roles.push(match[1])
|
||||
match = roleRegex.exec(expression)
|
||||
}
|
||||
|
||||
const cleaned = expression
|
||||
.replace(new RegExp(MANAGE_PLATFORM_EXPR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '')
|
||||
.replace(new RegExp(AUTHENTICATED_EXPR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '')
|
||||
.replace(roleRegex, '')
|
||||
.replace(/\s*\|\|\s*/g, '')
|
||||
.replace(/^\s+|\s+$/g, '')
|
||||
|
||||
if (cleaned) {
|
||||
return {
|
||||
mode: 'custom',
|
||||
includeManagePlatform: includeManagePlatform,
|
||||
roles: uniqueList(roles),
|
||||
rawExpression: expression,
|
||||
}
|
||||
}
|
||||
|
||||
if (includeAuthenticated && !roles.length) {
|
||||
return {
|
||||
mode: 'authenticated',
|
||||
includeManagePlatform: includeManagePlatform,
|
||||
roles: [],
|
||||
rawExpression: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'roleBased',
|
||||
includeManagePlatform: includeManagePlatform,
|
||||
roles: uniqueList(roles),
|
||||
rawExpression: '',
|
||||
}
|
||||
}
|
||||
|
||||
function listManageableCollections() {
|
||||
const data = requestPocketBase('GET', '/api/collections?page=1&perPage=200&sort=name', null, '查询集合列表')
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
|
||||
return items
|
||||
.filter(function (item) {
|
||||
return !item.system && item.type !== 'view' && String(item.name || '').indexOf('_') !== 0
|
||||
})
|
||||
.map(function (item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
listRule: item.listRule,
|
||||
viewRule: item.viewRule,
|
||||
createRule: item.createRule,
|
||||
updateRule: item.updateRule,
|
||||
deleteRule: item.deleteRule,
|
||||
parsedRules: {
|
||||
list: parseRuleExpression(item.listRule),
|
||||
view: parseRuleExpression(item.viewRule),
|
||||
create: parseRuleExpression(item.createRule),
|
||||
update: parseRuleExpression(item.updateRule),
|
||||
delete: parseRuleExpression(item.deleteRule),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function saveCollectionRules(payload) {
|
||||
const collectionName = normalizeText(payload.collection_name)
|
||||
if (!collectionName) {
|
||||
throw createAppError(400, 'collection_name 为必填项')
|
||||
}
|
||||
|
||||
const collections = listManageableCollections()
|
||||
const target = collections.find(function (item) {
|
||||
return item.name === collectionName
|
||||
})
|
||||
if (!target) {
|
||||
throw createAppError(404, '未找到可管理的集合:' + collectionName)
|
||||
}
|
||||
|
||||
const rulesPayload = {
|
||||
listRule: buildRuleExpression(payload.rules && payload.rules.list ? payload.rules.list : { mode: 'locked' }),
|
||||
viewRule: buildRuleExpression(payload.rules && payload.rules.view ? payload.rules.view : { mode: 'locked' }),
|
||||
createRule: buildRuleExpression(payload.rules && payload.rules.create ? payload.rules.create : { mode: 'locked' }),
|
||||
updateRule: buildRuleExpression(payload.rules && payload.rules.update ? payload.rules.update : { mode: 'locked' }),
|
||||
deleteRule: buildRuleExpression(payload.rules && payload.rules.delete ? payload.rules.delete : { mode: 'locked' }),
|
||||
}
|
||||
|
||||
requestPocketBase('PATCH', '/api/collections/' + encodeURIComponent(target.id), rulesPayload, '保存集合权限')
|
||||
|
||||
return {
|
||||
collection: listManageableCollections().find(function (item) {
|
||||
return item.name === collectionName
|
||||
}) || null,
|
||||
}
|
||||
}
|
||||
|
||||
function listUsers(keyword, roleMap) {
|
||||
const search = normalizeText(keyword).toLowerCase()
|
||||
const records = $app.findRecordsByFilter(USER_COLLECTION, '', '', 500, 0)
|
||||
const items = records.map(function (record) {
|
||||
const usergroupsId = record.getString('usergroups_id')
|
||||
const role = roleMap && roleMap[usergroupsId] ? roleMap[usergroupsId] : null
|
||||
return {
|
||||
pb_id: record.id,
|
||||
users_id: record.getString('users_id'),
|
||||
users_name: record.getString('users_name'),
|
||||
users_phone: record.getString('users_phone'),
|
||||
openid: record.getString('openid'),
|
||||
users_idtype: record.getString('users_idtype'),
|
||||
users_type: record.getString('users_type'),
|
||||
users_status: record.get('users_status'),
|
||||
users_rank_level: record.get('users_rank_level'),
|
||||
usergroups_id: usergroupsId,
|
||||
role_name: role ? role.role_name : '',
|
||||
created: String(record.created || ''),
|
||||
updated: String(record.updated || ''),
|
||||
}
|
||||
})
|
||||
|
||||
if (!search) {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.filter(function (item) {
|
||||
return String(item.users_name || '').toLowerCase().indexOf(search) !== -1
|
||||
|| String(item.users_phone || '').toLowerCase().indexOf(search) !== -1
|
||||
|| String(item.openid || '').toLowerCase().indexOf(search) !== -1
|
||||
|| String(item.role_name || '').toLowerCase().indexOf(search) !== -1
|
||||
})
|
||||
}
|
||||
|
||||
function updateUserRole(payload) {
|
||||
const userId = normalizeText(payload.pb_id)
|
||||
if (!userId) {
|
||||
throw createAppError(400, 'pb_id 为必填项')
|
||||
}
|
||||
|
||||
const collection = $app.findCollectionByNameOrId(USER_COLLECTION)
|
||||
const record = $app.findRecordById(collection, userId)
|
||||
if (!record) {
|
||||
throw createAppError(404, '未找到对应用户')
|
||||
}
|
||||
|
||||
const roleId = normalizeText(payload.usergroups_id)
|
||||
if (roleId && !findRoleRecordByRoleId(roleId)) {
|
||||
throw createAppError(400, '指定的 role_id 不存在')
|
||||
}
|
||||
|
||||
record.set('usergroups_id', roleId)
|
||||
|
||||
try {
|
||||
$app.save(record)
|
||||
} catch (err) {
|
||||
throw createAppError(400, '保存用户角色失败', {
|
||||
originalMessage: (err && err.message) || '未知错误',
|
||||
originalData: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
pb_id: record.id,
|
||||
users_id: record.getString('users_id'),
|
||||
users_name: record.getString('users_name'),
|
||||
users_phone: record.getString('users_phone'),
|
||||
openid: record.getString('openid'),
|
||||
users_idtype: record.getString('users_idtype'),
|
||||
users_type: record.getString('users_type'),
|
||||
users_status: record.get('users_status'),
|
||||
users_rank_level: record.get('users_rank_level'),
|
||||
usergroups_id: record.getString('usergroups_id'),
|
||||
created: String(record.created || ''),
|
||||
updated: String(record.updated || ''),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRole(roleId) {
|
||||
const value = normalizeText(roleId)
|
||||
if (!value) {
|
||||
throw createAppError(400, 'role_id 为必填项')
|
||||
}
|
||||
|
||||
const record = findRoleRecordByRoleId(value)
|
||||
if (!record) {
|
||||
throw createAppError(404, '未找到对应角色')
|
||||
}
|
||||
|
||||
const users = $app.findRecordsByFilter(USER_COLLECTION, 'usergroups_id = {:roleId}', '', 500, 0, {
|
||||
roleId: value,
|
||||
})
|
||||
for (let i = 0; i < users.length; i += 1) {
|
||||
users[i].set('usergroups_id', '')
|
||||
$app.save(users[i])
|
||||
}
|
||||
|
||||
const collections = listManageableCollections()
|
||||
for (let i = 0; i < collections.length; i += 1) {
|
||||
const item = collections[i]
|
||||
let changed = false
|
||||
const nextRules = {}
|
||||
|
||||
;['list', 'view', 'create', 'update', 'delete'].forEach(function (operation) {
|
||||
const config = item.parsedRules[operation]
|
||||
if (!config || config.mode === 'custom') {
|
||||
nextRules[operation] = config
|
||||
return
|
||||
}
|
||||
|
||||
const nextRoles = uniqueList((config.roles || []).filter(function (currentRoleId) {
|
||||
return currentRoleId !== value
|
||||
}))
|
||||
if (nextRoles.length !== (config.roles || []).length) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
nextRules[operation] = {
|
||||
mode: config.mode,
|
||||
includeManagePlatform: !!config.includeManagePlatform,
|
||||
roles: nextRoles,
|
||||
rawExpression: config.rawExpression || '',
|
||||
}
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
saveCollectionRules({
|
||||
collection_name: item.name,
|
||||
rules: nextRules,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$app.delete(record)
|
||||
|
||||
return {
|
||||
deleted_role_id: value,
|
||||
}
|
||||
}
|
||||
|
||||
function syncManagePlatformFullAccess() {
|
||||
const collections = listManageableCollections()
|
||||
|
||||
for (let i = 0; i < collections.length; i += 1) {
|
||||
const item = collections[i]
|
||||
const nextRules = {}
|
||||
|
||||
;['list', 'view', 'create', 'update', 'delete'].forEach(function (operation) {
|
||||
const config = item.parsedRules[operation]
|
||||
if (config && config.mode === 'custom') {
|
||||
nextRules[operation] = {
|
||||
mode: 'custom',
|
||||
includeManagePlatform: true,
|
||||
roles: config.roles || [],
|
||||
rawExpression: config.rawExpression || '',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
nextRules[operation] = {
|
||||
mode: config ? config.mode : 'roleBased',
|
||||
includeManagePlatform: true,
|
||||
roles: config ? (config.roles || []) : [],
|
||||
rawExpression: config ? (config.rawExpression || '') : '',
|
||||
}
|
||||
|
||||
if (nextRules[operation].mode === 'locked' && !nextRules[operation].roles.length) {
|
||||
nextRules[operation].mode = 'roleBased'
|
||||
}
|
||||
})
|
||||
|
||||
saveCollectionRules({
|
||||
collection_name: item.name,
|
||||
rules: nextRules,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
count: collections.length,
|
||||
}
|
||||
}
|
||||
|
||||
function getManagementContext(keyword) {
|
||||
const roles = listRoles()
|
||||
const roleMap = buildRoleMap(roles)
|
||||
return {
|
||||
roles: roles,
|
||||
users: listUsers(keyword, roleMap),
|
||||
collections: listManageableCollections(),
|
||||
note: '先创建角色,再在“用户授权”里把用户绑定到角色,最后切到下方“当前配置角色”为该角色逐表勾选 CRUD 权限。ManagePlatform 仍是业务管理员,不会变成 PocketBase 原生 _superusers。',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getManagementContext,
|
||||
listRoles,
|
||||
saveRole,
|
||||
deleteRole,
|
||||
listUsers,
|
||||
updateUserRole,
|
||||
listManageableCollections,
|
||||
saveCollectionRules,
|
||||
syncManagePlatformFullAccess,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
|
||||
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
|
||||
const wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/wechatService.js`)
|
||||
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
|
||||
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
|
||||
|
||||
const GUEST_USER_TYPE = '游客'
|
||||
@@ -124,10 +125,68 @@ function exportCompany(companyRecord) {
|
||||
return companyRecord.publicExport()
|
||||
}
|
||||
|
||||
function resolveUserAttachment(attachmentId) {
|
||||
const value = String(attachmentId || '')
|
||||
if (!value) {
|
||||
return {
|
||||
id: '',
|
||||
url: '',
|
||||
attachment: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const attachment = documentService.getAttachmentDetail(value)
|
||||
return {
|
||||
id: value,
|
||||
url: attachment ? attachment.attachments_url : '',
|
||||
attachment: attachment,
|
||||
}
|
||||
} catch (_error) {
|
||||
return {
|
||||
id: value,
|
||||
url: '',
|
||||
attachment: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAttachmentIdExists(attachmentId, fieldName) {
|
||||
const value = String(attachmentId || '')
|
||||
if (!value) return ''
|
||||
|
||||
try {
|
||||
documentService.getAttachmentDetail(value)
|
||||
} catch (_error) {
|
||||
throw createAppError(400, fieldName + ' 对应的附件不存在:' + value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function applyUserAttachmentFields(record, payload) {
|
||||
if (!payload) return
|
||||
|
||||
record.set('users_picture', ensureAttachmentIdExists(payload.users_picture || '', 'users_picture'))
|
||||
if (typeof payload.users_id_pic_a !== 'undefined') {
|
||||
record.set('users_id_pic_a', ensureAttachmentIdExists(payload.users_id_pic_a || '', 'users_id_pic_a'))
|
||||
}
|
||||
if (typeof payload.users_id_pic_b !== 'undefined') {
|
||||
record.set('users_id_pic_b', ensureAttachmentIdExists(payload.users_id_pic_b || '', 'users_id_pic_b'))
|
||||
}
|
||||
if (typeof payload.users_title_picture !== 'undefined') {
|
||||
record.set('users_title_picture', ensureAttachmentIdExists(payload.users_title_picture || '', 'users_title_picture'))
|
||||
}
|
||||
}
|
||||
|
||||
function enrichUser(userRecord) {
|
||||
const companyId = userRecord.getString('company_id')
|
||||
const companyRecord = getCompanyByCompanyId(companyId)
|
||||
const openid = userRecord.getString('openid')
|
||||
const userPicture = resolveUserAttachment(userRecord.getString('users_picture'))
|
||||
const userIdPicA = resolveUserAttachment(userRecord.getString('users_id_pic_a'))
|
||||
const userIdPicB = resolveUserAttachment(userRecord.getString('users_id_pic_b'))
|
||||
const userTitlePicture = resolveUserAttachment(userRecord.getString('users_title_picture'))
|
||||
|
||||
return {
|
||||
pb_id: userRecord.id,
|
||||
@@ -143,7 +202,18 @@ function enrichUser(userRecord) {
|
||||
users_phone: userRecord.getString('users_phone'),
|
||||
users_phone_masked: maskPhone(userRecord.getString('users_phone')),
|
||||
users_level: userRecord.getString('users_level'),
|
||||
users_picture: userRecord.getString('users_picture'),
|
||||
users_picture: userPicture.id,
|
||||
users_picture_url: userPicture.url,
|
||||
users_picture_attachment: userPicture.attachment,
|
||||
users_id_pic_a: userIdPicA.id,
|
||||
users_id_pic_a_url: userIdPicA.url,
|
||||
users_id_pic_a_attachment: userIdPicA.attachment,
|
||||
users_id_pic_b: userIdPicB.id,
|
||||
users_id_pic_b_url: userIdPicB.url,
|
||||
users_id_pic_b_attachment: userIdPicB.attachment,
|
||||
users_title_picture: userTitlePicture.id,
|
||||
users_title_picture_url: userTitlePicture.url,
|
||||
users_title_picture_attachment: userTitlePicture.attachment,
|
||||
openid: openid,
|
||||
company_id: companyId || '',
|
||||
users_parent_id: userRecord.getString('users_parent_id'),
|
||||
@@ -293,7 +363,6 @@ function registerPlatformUser(payload) {
|
||||
record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE)
|
||||
record.set('users_name', payload.users_name)
|
||||
record.set('users_phone', payload.users_phone)
|
||||
record.set('users_picture', payload.users_picture)
|
||||
record.set('users_id_number', payload.users_id_number || '')
|
||||
record.set('users_level', payload.users_level || '')
|
||||
record.set('users_type', payload.users_type || REGISTERED_USER_TYPE)
|
||||
@@ -305,6 +374,7 @@ function registerPlatformUser(payload) {
|
||||
record.set('email', payload.email || (platformOpenid + '@manage.local'))
|
||||
record.setPassword(payload.password)
|
||||
record.set('passwordConfirm', payload.passwordConfirm)
|
||||
applyUserAttachmentFields(record, payload)
|
||||
|
||||
saveAuthUserRecord(record)
|
||||
|
||||
@@ -432,7 +502,7 @@ function updateWechatUserProfile(usersWxOpenid, payload) {
|
||||
|
||||
currentUser.set('users_name', payload.users_name)
|
||||
currentUser.set('users_phone', usersPhone)
|
||||
currentUser.set('users_picture', payload.users_picture)
|
||||
applyUserAttachmentFields(currentUser, payload)
|
||||
if (shouldPromote) {
|
||||
currentUser.set('users_type', REGISTERED_USER_TYPE)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
function normalizeErrorData(data) {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const result = {}
|
||||
const keys = Object.keys(data)
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const key = keys[i]
|
||||
const value = data[key]
|
||||
|
||||
if (typeof value === 'undefined') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
result[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
result[key] = JSON.stringify(value)
|
||||
} catch (_err) {
|
||||
result[key] = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function createAppError(statusCode, message, data) {
|
||||
return new ApiError(statusCode || 500, message || '服务器内部错误', data || {})
|
||||
const error = new ApiError(statusCode || 500, message || '服务器内部错误')
|
||||
error.statusCode = statusCode || 500
|
||||
error.status = statusCode || 500
|
||||
error.data = normalizeErrorData(data)
|
||||
return error
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -58,6 +58,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
.modal-actions { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.empty { text-align: center; padding: 32px 16px; color: #64748b; }
|
||||
.thumb { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; }
|
||||
.thumb.previewable { cursor: zoom-in; }
|
||||
.thumb-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.thumb-meta { font-size: 12px; color: #64748b; word-break: break-all; }
|
||||
.enum-preview-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
@@ -67,6 +68,16 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
.image-upload-row input[type="file"] { width: 100%; min-width: 0; }
|
||||
.icon-btn { min-width: 36px; padding: 6px 10px; line-height: 1; font-size: 18px; }
|
||||
.drop-tip { color: #64748b; font-size: 12px; margin-top: 6px; }
|
||||
.image-viewer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.82); z-index: 9999; }
|
||||
.image-viewer.show { display: flex; }
|
||||
.image-viewer img { max-width: min(92vw, 1600px); max-height: 88vh; border-radius: 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.38); background: #fff; }
|
||||
.image-viewer-close { position: absolute; top: 18px; right: 18px; min-width: 44px; min-height: 44px; border-radius: 999px; border: none; background: rgba(255,255,255,0.92); color: #0f172a; font-size: 24px; cursor: pointer; }
|
||||
.loading-mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(4px); z-index: 9998; }
|
||||
.loading-mask.show { display: flex; }
|
||||
.loading-card { min-width: min(92vw, 360px); padding: 24px 22px; border-radius: 20px; background: rgba(255,255,255,0.98); box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); border: 1px solid #dbe3f0; text-align: center; }
|
||||
.loading-spinner { width: 44px; height: 44px; margin: 0 auto 14px; border-radius: 999px; border: 4px solid #dbeafe; border-top-color: #2563eb; animation: dictSpin 0.9s linear infinite; }
|
||||
.loading-text { color: #0f172a; font-size: 15px; font-weight: 700; }
|
||||
@keyframes dictSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@media (max-width: 960px) {
|
||||
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
|
||||
table, thead, tbody, th, td, tr { display: block; }
|
||||
@@ -169,6 +180,18 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer" id="imageViewer">
|
||||
<button class="image-viewer-close" id="closeImageViewerBtn" type="button">×</button>
|
||||
<img id="imageViewerImg" src="" alt="预览原图" />
|
||||
</div>
|
||||
|
||||
<div class="loading-mask" id="loadingMask">
|
||||
<div class="loading-card">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text" id="loadingText">处理中,请稍候...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '/pb/api'
|
||||
const tokenKey = 'pb_manage_token'
|
||||
@@ -193,12 +216,43 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
const enabledInput = document.getElementById('enabledInput')
|
||||
const remarkInput = document.getElementById('remarkInput')
|
||||
const itemsBody = document.getElementById('itemsBody')
|
||||
const imageViewer = document.getElementById('imageViewer')
|
||||
const imageViewerImg = document.getElementById('imageViewerImg')
|
||||
const loadingMask = document.getElementById('loadingMask')
|
||||
const loadingText = document.getElementById('loadingText')
|
||||
const loadingState = { count: 0 }
|
||||
|
||||
function setStatus(message, type) {
|
||||
statusEl.textContent = message || ''
|
||||
statusEl.className = 'status' + (type ? ' ' + type : '')
|
||||
}
|
||||
|
||||
function showLoading(message) {
|
||||
loadingState.count += 1
|
||||
if (loadingText) {
|
||||
loadingText.textContent = message || '处理中,请稍候...'
|
||||
}
|
||||
if (loadingMask) {
|
||||
loadingMask.classList.add('show')
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
loadingState.count = Math.max(0, loadingState.count - 1)
|
||||
if (loadingState.count === 0 && loadingMask) {
|
||||
loadingMask.classList.remove('show')
|
||||
if (loadingText) {
|
||||
loadingText.textContent = '处理中,请稍候...'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeJsString(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\\\/g, '\\\\\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(tokenKey) || ''
|
||||
}
|
||||
@@ -235,6 +289,36 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
async function parseJsonSafe(res) {
|
||||
const contentType = String(res.headers.get('content-type') || '').toLowerCase()
|
||||
const rawText = await res.text()
|
||||
const isJson = contentType.indexOf('application/json') !== -1
|
||||
|
||||
if (!rawText) {
|
||||
return {
|
||||
json: null,
|
||||
text: '',
|
||||
isJson: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
try {
|
||||
return {
|
||||
json: JSON.parse(rawText),
|
||||
text: rawText,
|
||||
isJson: true,
|
||||
}
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
return {
|
||||
json: null,
|
||||
text: rawText,
|
||||
isJson: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAttachment(file, label) {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
@@ -259,8 +343,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
body: form,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
const parsed = await parseJsonSafe(res)
|
||||
const data = parsed.json
|
||||
if (!res.ok || !data || data.code >= 400) {
|
||||
if (res.status === 413) {
|
||||
throw new Error('上传图片失败:文件已超过当前网关允许的请求体大小,或线上服务仍在运行旧版 hooks。')
|
||||
}
|
||||
if (!parsed.isJson && parsed.text) {
|
||||
throw new Error('上传图片失败:服务端返回了非 JSON 响应,通常表示网关或反向代理提前拦截了上传请求。')
|
||||
}
|
||||
throw new Error((data && data.msg) || '上传图片失败')
|
||||
}
|
||||
|
||||
@@ -333,9 +424,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
function renderEnumPreviewItem(item) {
|
||||
const desc = escapeHtml(item && item.description ? item.description : '(无描述)')
|
||||
const imageHtml = item && item.imageUrl
|
||||
? '<img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" />'
|
||||
? '<img class="thumb previewable" src="' + escapeHtml(item.imageUrl) + '" alt="" onclick="window.__previewImage(\\'' + escapeJsString(item.imageUrl) + '\\')" />'
|
||||
: '<span class="muted">无图</span>'
|
||||
return '<div class="enum-preview-item"><span>' + desc + '</span>' + imageHtml + '</div>'
|
||||
return '<div class="enum-preview-item">' + imageHtml + '<span>' + desc + '</span></div>'
|
||||
}
|
||||
|
||||
function renderItemsPreview(items, previewKey, isExpanded) {
|
||||
@@ -443,7 +534,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
function renderItemsEditor() {
|
||||
itemsBody.innerHTML = state.items.map(function (item, index) {
|
||||
const imageCell = item.imageUrl
|
||||
? '<div class="thumb-row"><img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" /><div class="thumb-meta">' + escapeHtml(item.image || '') + '</div></div>'
|
||||
? '<div class="thumb-row"><img class="thumb previewable" src="' + escapeHtml(item.imageUrl) + '" alt="" onclick="window.__previewImage(\\'' + escapeJsString(item.imageUrl) + '\\')" /><div class="thumb-meta">' + escapeHtml(item.image || '') + '</div></div>'
|
||||
: '<div class="muted">未上传图片</div>'
|
||||
return '<tr>'
|
||||
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>'
|
||||
@@ -472,6 +563,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus('正在上传字典项图片...', '')
|
||||
showLoading('正在上传字典项图片,请稍候...')
|
||||
try {
|
||||
const attachment = await uploadAttachment(file, 'dict-item-' + (index + 1))
|
||||
state.items[index].image = attachment.attachments_id
|
||||
@@ -481,6 +573,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
setStatus('字典项图片上传成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '字典项图片上传失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +609,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
|
||||
async function loadList() {
|
||||
setStatus('正在查询字典列表...', '')
|
||||
showLoading('正在查询字典列表...')
|
||||
try {
|
||||
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
|
||||
state.list = data.items || []
|
||||
@@ -523,6 +618,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '查询失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +631,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus('正在查询字典详情...', '')
|
||||
showLoading('正在查询字典详情...')
|
||||
try {
|
||||
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
|
||||
state.list = [data]
|
||||
@@ -542,6 +640,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
setStatus('查询详情成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '查询失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +659,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus('正在保存字典...', '')
|
||||
showLoading('正在保存字典,请稍候...')
|
||||
try {
|
||||
await request(state.mode === 'create' ? API_BASE + '/dictionary/create' : API_BASE + '/dictionary/update', payload)
|
||||
closeModal()
|
||||
@@ -566,6 +667,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
setStatus(state.mode === 'create' ? '新增成功。' : '修改成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '保存失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,12 +694,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus('正在保存行数据...', '')
|
||||
showLoading('正在保存行数据,请稍候...')
|
||||
try {
|
||||
await request(API_BASE + '/dictionary/update', payload)
|
||||
await loadList()
|
||||
setStatus('行内保存成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '保存失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,12 +713,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus('正在删除字典...', '')
|
||||
showLoading('正在删除字典,请稍候...')
|
||||
try {
|
||||
await request(API_BASE + '/dictionary/delete', { dict_name: targetName })
|
||||
await loadList()
|
||||
setStatus('删除成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '删除失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +741,13 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
state.expandedPreviewKey = state.expandedPreviewKey === previewKey ? '' : previewKey
|
||||
renderTable(state.list)
|
||||
}
|
||||
window.__previewImage = function (url) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
imageViewerImg.src = url
|
||||
imageViewer.classList.add('show')
|
||||
}
|
||||
window.__uploadItemImage = uploadItemImage
|
||||
window.__allowItemDrop = function (event) {
|
||||
event.preventDefault()
|
||||
@@ -675,6 +791,22 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
||||
document.getElementById('createBtn').addEventListener('click', function () { openModal('create') })
|
||||
document.getElementById('closeModalBtn').addEventListener('click', closeModal)
|
||||
document.getElementById('cancelBtn').addEventListener('click', closeModal)
|
||||
document.getElementById('closeImageViewerBtn').addEventListener('click', function () {
|
||||
imageViewer.classList.remove('show')
|
||||
imageViewerImg.src = ''
|
||||
})
|
||||
imageViewer.addEventListener('click', function (event) {
|
||||
if (event.target === imageViewer) {
|
||||
imageViewer.classList.remove('show')
|
||||
imageViewerImg.src = ''
|
||||
}
|
||||
})
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && imageViewer.classList.contains('show')) {
|
||||
imageViewer.classList.remove('show')
|
||||
imageViewerImg.src = ''
|
||||
}
|
||||
})
|
||||
document.getElementById('addItemBtn').addEventListener('click', function () {
|
||||
syncItemsStateFromEditor()
|
||||
const used = new Set(state.items.map(function (item) { return String(item.enum || '') }))
|
||||
|
||||
@@ -30,6 +30,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
.status { margin-top: 14px; min-height: 24px; font-size: 14px; }
|
||||
.status.success { color: #15803d; }
|
||||
.status.error { color: #b91c1c; }
|
||||
.editor-panel { display: none; }
|
||||
.editor-panel.show { display: block; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.full { grid-column: 1 / -1; }
|
||||
label { display: block; margin-bottom: 8px; color: #334155; font-weight: 600; }
|
||||
@@ -46,7 +48,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
.doc-links a, .file-card a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; }
|
||||
.btn-warning { background: #f59e0b; color: #fff; }
|
||||
.editor-banner { display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 13px; font-weight: 700; }
|
||||
.file-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.file-group { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.file-box { border: 1px solid #dbe3f0; border-radius: 18px; padding: 16px; background: #f8fbff; }
|
||||
.option-box { border: 1px solid #dbe3f0; border-radius: 16px; background: #f8fbff; padding: 14px; }
|
||||
.option-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
@@ -68,6 +70,18 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
.file-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.file-card-title { font-weight: 700; word-break: break-all; }
|
||||
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; }
|
||||
.thumb { width: 72px; height: 72px; border-radius: 12px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; cursor: zoom-in; }
|
||||
.thumb-strip { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||
.image-viewer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.82); z-index: 9999; }
|
||||
.image-viewer.show { display: flex; }
|
||||
.image-viewer img { max-width: min(92vw, 1600px); max-height: 88vh; border-radius: 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.38); background: #fff; }
|
||||
.image-viewer-close { position: absolute; top: 18px; right: 18px; min-width: 44px; min-height: 44px; border-radius: 999px; border: none; background: rgba(255,255,255,0.92); color: #0f172a; font-size: 24px; cursor: pointer; }
|
||||
.loading-mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(4px); z-index: 9998; }
|
||||
.loading-mask.show { display: flex; }
|
||||
.loading-card { min-width: min(92vw, 360px); padding: 24px 22px; border-radius: 20px; background: rgba(255,255,255,0.98); box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); border: 1px solid #dbe3f0; text-align: center; }
|
||||
.loading-spinner { width: 44px; height: 44px; margin: 0 auto 14px; border-radius: 999px; border: 4px solid #dbeafe; border-top-color: #2563eb; animation: docSpin 0.9s linear infinite; }
|
||||
.loading-text { color: #0f172a; font-size: 15px; font-weight: 700; }
|
||||
@keyframes docSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@media (max-width: 960px) {
|
||||
.grid, .file-group { grid-template-columns: 1fr; }
|
||||
.option-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -92,7 +106,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
<div class="status" id="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel editor-panel" id="editorPanel">
|
||||
<div class="toolbar" style="justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<h2 id="formTitle">新增文档</h2>
|
||||
@@ -213,12 +227,23 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
<div class="file-list" id="videoCurrentList"></div>
|
||||
<div class="file-list" id="videoPendingList"></div>
|
||||
</div>
|
||||
<div class="file-box">
|
||||
<h3>文件附件</h3>
|
||||
<label for="documentFile">新增文件</label>
|
||||
<div class="dropzone" id="documentFileDropzone">
|
||||
<div class="dropzone-title">拖拽文件到这里,或点击选择文件</div>
|
||||
<input id="documentFile" type="file" multiple />
|
||||
</div>
|
||||
<div class="file-list" id="documentFileCurrentList"></div>
|
||||
<div class="file-list" id="documentFilePendingList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:16px;">
|
||||
<button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button>
|
||||
<button class="btn btn-warning" id="cancelEditBtn" type="button">取消编辑</button>
|
||||
<button class="btn btn-light" id="resetBtn" type="button">重置表单</button>
|
||||
</div>
|
||||
<div class="status" id="editorStatus"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -242,10 +267,24 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="image-viewer" id="imageViewer">
|
||||
<button class="image-viewer-close" id="closeImageViewerBtn" type="button">×</button>
|
||||
<img id="imageViewerImg" src="" alt="预览原图" />
|
||||
</div>
|
||||
|
||||
<div class="loading-mask" id="loadingMask">
|
||||
<div class="loading-card">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text" id="loadingText">处理中,请稍候...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tokenKey = 'pb_manage_token'
|
||||
const API_BASE = '/pb/api'
|
||||
const statusEl = document.getElementById('status')
|
||||
const editorStatusEl = document.getElementById('editorStatus')
|
||||
const editorPanelEl = document.getElementById('editorPanel')
|
||||
const tableBody = document.getElementById('tableBody')
|
||||
const formTitleEl = document.getElementById('formTitle')
|
||||
const editorModeEl = document.getElementById('editorMode')
|
||||
@@ -253,8 +292,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
const imagePendingListEl = document.getElementById('imagePendingList')
|
||||
const videoCurrentListEl = document.getElementById('videoCurrentList')
|
||||
const videoPendingListEl = document.getElementById('videoPendingList')
|
||||
const documentFileCurrentListEl = document.getElementById('documentFileCurrentList')
|
||||
const documentFilePendingListEl = document.getElementById('documentFilePendingList')
|
||||
const imageDropzoneEl = document.getElementById('imageDropzone')
|
||||
const videoDropzoneEl = document.getElementById('videoDropzone')
|
||||
const documentFileDropzoneEl = document.getElementById('documentFileDropzone')
|
||||
const documentTypeSourceEl = document.getElementById('documentTypeSource')
|
||||
const documentTypeOptionsEl = document.getElementById('documentTypeOptions')
|
||||
const documentTypeTagsEl = document.getElementById('documentTypeTags')
|
||||
@@ -267,6 +309,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
const applicationScenariosTagsEl = document.getElementById('applicationScenariosTags')
|
||||
const hotelTypeOptionsEl = document.getElementById('hotelTypeOptions')
|
||||
const hotelTypeTagsEl = document.getElementById('hotelTypeTags')
|
||||
const imageViewerEl = document.getElementById('imageViewer')
|
||||
const imageViewerImgEl = document.getElementById('imageViewerImg')
|
||||
const loadingMaskEl = document.getElementById('loadingMask')
|
||||
const loadingTextEl = document.getElementById('loadingText')
|
||||
const loadingState = { count: 0 }
|
||||
const fields = {
|
||||
documentTitle: document.getElementById('documentTitle'),
|
||||
documentStatus: document.getElementById('documentStatus'),
|
||||
@@ -281,6 +328,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
documentRemark: document.getElementById('documentRemark'),
|
||||
imageFile: document.getElementById('imageFile'),
|
||||
videoFile: document.getElementById('videoFile'),
|
||||
documentFile: document.getElementById('documentFile'),
|
||||
}
|
||||
const dictionaryFieldConfig = {
|
||||
documentKeywords: {
|
||||
@@ -306,13 +354,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
const state = {
|
||||
list: [],
|
||||
mode: 'create',
|
||||
mode: 'idle',
|
||||
editingId: '',
|
||||
editingSource: null,
|
||||
currentImageAttachments: [],
|
||||
currentVideoAttachments: [],
|
||||
currentFileAttachments: [],
|
||||
pendingImageFiles: [],
|
||||
pendingVideoFiles: [],
|
||||
pendingDocumentFiles: [],
|
||||
removedAttachmentIds: [],
|
||||
dictionaries: [],
|
||||
dictionariesByName: {},
|
||||
@@ -330,6 +380,43 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
function setStatus(message, type) {
|
||||
statusEl.textContent = message || ''
|
||||
statusEl.className = 'status' + (type ? ' ' + type : '')
|
||||
if (editorStatusEl) {
|
||||
editorStatusEl.textContent = message || ''
|
||||
editorStatusEl.className = 'status' + (type ? ' ' + type : '')
|
||||
}
|
||||
}
|
||||
|
||||
function setEditorVisible(visible) {
|
||||
if (!editorPanelEl) {
|
||||
return
|
||||
}
|
||||
editorPanelEl.classList.toggle('show', !!visible)
|
||||
}
|
||||
|
||||
function showLoading(message) {
|
||||
loadingState.count += 1
|
||||
if (loadingTextEl) {
|
||||
loadingTextEl.textContent = message || '处理中,请稍候...'
|
||||
}
|
||||
if (loadingMaskEl) {
|
||||
loadingMaskEl.classList.add('show')
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
loadingState.count = Math.max(0, loadingState.count - 1)
|
||||
if (loadingState.count === 0 && loadingMaskEl) {
|
||||
loadingMaskEl.classList.remove('show')
|
||||
if (loadingTextEl) {
|
||||
loadingTextEl.textContent = '处理中,请稍候...'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeJsString(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\\\/g, '\\\\\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
@@ -549,29 +636,40 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
|
||||
async function loadDictionaries() {
|
||||
const data = await requestJson('/dictionary/list', {})
|
||||
state.dictionaries = Array.isArray(data.items) ? data.items : []
|
||||
state.dictionariesByName = {}
|
||||
state.dictionariesById = {}
|
||||
showLoading('正在加载字典选项...')
|
||||
try {
|
||||
const data = await requestJson('/dictionary/list', {})
|
||||
state.dictionaries = Array.isArray(data.items) ? data.items : []
|
||||
state.dictionariesByName = {}
|
||||
state.dictionariesById = {}
|
||||
|
||||
for (let i = 0; i < state.dictionaries.length; i += 1) {
|
||||
const item = state.dictionaries[i]
|
||||
state.dictionariesByName[item.dict_name] = item
|
||||
state.dictionariesById[item.system_dict_id] = item
|
||||
for (let i = 0; i < state.dictionaries.length; i += 1) {
|
||||
const item = state.dictionaries[i]
|
||||
state.dictionariesByName[item.dict_name] = item
|
||||
state.dictionariesById[item.system_dict_id] = item
|
||||
}
|
||||
|
||||
renderDictionarySelectors()
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
|
||||
renderDictionarySelectors()
|
||||
}
|
||||
|
||||
function updateEditorMode() {
|
||||
setEditorVisible(state.mode === 'create' || state.mode === 'edit')
|
||||
|
||||
if (state.mode === 'edit') {
|
||||
formTitleEl.textContent = '编辑文档'
|
||||
editorModeEl.textContent = '当前模式:编辑 ' + state.editingId
|
||||
document.getElementById('submitBtn').textContent = '保存文档修改'
|
||||
} else {
|
||||
} else if (state.mode === 'create') {
|
||||
formTitleEl.textContent = '新增文档'
|
||||
editorModeEl.textContent = '当前模式:新建'
|
||||
document.getElementById('submitBtn').textContent = '上传附件并创建文档'
|
||||
} else {
|
||||
formTitleEl.textContent = '新增文档'
|
||||
editorModeEl.textContent = '当前模式:未打开编辑区'
|
||||
document.getElementById('submitBtn').textContent = '上传附件并创建文档'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,7 +696,12 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
localStorage.removeItem('pb_manage_logged_in')
|
||||
window.location.replace('/pb/manage/login')
|
||||
}
|
||||
throw new Error((data && data.msg) || '请求失败')
|
||||
const details = data && data.data ? data.data : {}
|
||||
const detailMessage = details.originalMessage || details.body || ''
|
||||
const finalMessage = [(data && data.msg) || '请求失败', detailMessage].filter(function (item, index, arr) {
|
||||
return item && arr.indexOf(item) === index
|
||||
}).join(':')
|
||||
throw new Error(finalMessage || '请求失败')
|
||||
}
|
||||
|
||||
return data.data
|
||||
@@ -661,7 +764,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
const data = parsed.json
|
||||
if (!res.ok || !data || data.code >= 400) {
|
||||
if (res.status === 413) {
|
||||
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小。当前附件字段已放宽到约 4GB,但线上反向代理也需要同步放开到相应体积。')
|
||||
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小,或线上服务仍在运行旧版 hooks。')
|
||||
}
|
||||
|
||||
if (!parsed.isJson && parsed.text) {
|
||||
@@ -693,6 +796,26 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function getAttachmentPreviewUrl(item, pending) {
|
||||
if (!item) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
return item.previewUrl || ''
|
||||
}
|
||||
|
||||
return item.attachments_url || ''
|
||||
}
|
||||
|
||||
function renderImageThumb(url, title) {
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return '<img class="thumb" src="' + escapeHtml(url) + '" alt="' + escapeHtml(title || '') + '" onclick="window.__previewImage(\\'' + escapeJsString(url) + '\\')" />'
|
||||
}
|
||||
|
||||
function renderAttachmentCards(container, items, category, pending) {
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="muted">' + (pending ? '暂无待上传附件。' : '暂无已绑定附件。') + '</div>'
|
||||
@@ -709,11 +832,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
const linkHtml = pending || !item.attachments_url
|
||||
? ''
|
||||
: '<a href="' + escapeHtml(item.attachments_url) + '" target="_blank" rel="noreferrer">打开文件</a>'
|
||||
const previewHtml = category === 'image'
|
||||
? renderImageThumb(getAttachmentPreviewUrl(item, pending), title)
|
||||
: ''
|
||||
const actionLabel = pending ? '移除待上传' : '从文档移除'
|
||||
const handler = pending ? '__removePendingAttachment' : '__removeCurrentAttachment'
|
||||
|
||||
return '<div class="file-card">'
|
||||
+ '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div></div>'
|
||||
+ previewHtml
|
||||
+ '<div class="file-meta">' + meta + '</div>'
|
||||
+ '<div class="file-actions">'
|
||||
+ linkHtml
|
||||
@@ -726,25 +853,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
function renderAttachmentEditors() {
|
||||
renderAttachmentCards(imageCurrentListEl, state.currentImageAttachments, 'image', false)
|
||||
renderAttachmentCards(videoCurrentListEl, state.currentVideoAttachments, 'video', false)
|
||||
renderAttachmentCards(documentFileCurrentListEl, state.currentFileAttachments, 'file', false)
|
||||
renderAttachmentCards(imagePendingListEl, state.pendingImageFiles, 'image', true)
|
||||
renderAttachmentCards(videoPendingListEl, state.pendingVideoFiles, 'video', true)
|
||||
renderAttachmentCards(documentFilePendingListEl, state.pendingDocumentFiles, 'file', true)
|
||||
}
|
||||
|
||||
function renderLinks(item) {
|
||||
const links = []
|
||||
const imageThumbs = []
|
||||
const imageUrls = Array.isArray(item.document_image_urls) ? item.document_image_urls : (item.document_image_url ? [item.document_image_url] : [])
|
||||
const videoUrls = Array.isArray(item.document_video_urls) ? item.document_video_urls : (item.document_video_url ? [item.document_video_url] : [])
|
||||
const fileUrls = Array.isArray(item.document_file_urls) ? item.document_file_urls : (item.document_file_url ? [item.document_file_url] : [])
|
||||
|
||||
for (let i = 0; i < imageUrls.length; i += 1) {
|
||||
links.push('<a href="' + escapeHtml(imageUrls[i]) + '" target="_blank" rel="noreferrer">图片流' + (i + 1) + '</a>')
|
||||
imageThumbs.push(renderImageThumb(imageUrls[i], '图片流' + (i + 1)))
|
||||
}
|
||||
for (let i = 0; i < videoUrls.length; i += 1) {
|
||||
links.push('<a href="' + escapeHtml(videoUrls[i]) + '" target="_blank" rel="noreferrer">视频流' + (i + 1) + '</a>')
|
||||
}
|
||||
for (let i = 0; i < fileUrls.length; i += 1) {
|
||||
links.push('<a href="' + escapeHtml(fileUrls[i]) + '" target="_blank" rel="noreferrer">文件流' + (i + 1) + '</a>')
|
||||
}
|
||||
if (!links.length) {
|
||||
return '<span class="muted">无</span>'
|
||||
}
|
||||
return '<div class="doc-links">' + links.join('') + '</div>'
|
||||
+ (imageThumbs.length ? '<div class="thumb-strip">' + imageThumbs.join('') + '</div>' : '')
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
@@ -768,17 +904,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
|
||||
function appendPendingFiles(category, fileList) {
|
||||
const target = category === 'image' ? state.pendingImageFiles : state.pendingVideoFiles
|
||||
const target = category === 'image'
|
||||
? state.pendingImageFiles
|
||||
: (category === 'video' ? state.pendingVideoFiles : state.pendingDocumentFiles)
|
||||
const files = Array.from(fileList || [])
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
target.push({
|
||||
key: Date.now() + '-' + Math.random().toString(36).slice(2),
|
||||
file: files[i],
|
||||
previewUrl: category === 'image' && files[i] ? URL.createObjectURL(files[i]) : '',
|
||||
})
|
||||
}
|
||||
renderAttachmentEditors()
|
||||
}
|
||||
|
||||
function revokePendingPreview(item) {
|
||||
if (item && item.previewUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(item.previewUrl)
|
||||
} catch (_error) {}
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingList(list) {
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
revokePendingPreview(list[i])
|
||||
}
|
||||
}
|
||||
|
||||
function bindDropzone(dropzoneEl, inputEl, category) {
|
||||
if (!dropzoneEl || !inputEl) {
|
||||
return
|
||||
@@ -818,15 +971,20 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
|
||||
function removePendingAttachment(category, index) {
|
||||
const target = category === 'image' ? state.pendingImageFiles : state.pendingVideoFiles
|
||||
const target = category === 'image'
|
||||
? state.pendingImageFiles
|
||||
: (category === 'video' ? state.pendingVideoFiles : state.pendingDocumentFiles)
|
||||
if (index >= 0 && index < target.length) {
|
||||
revokePendingPreview(target[index])
|
||||
target.splice(index, 1)
|
||||
}
|
||||
renderAttachmentEditors()
|
||||
}
|
||||
|
||||
function removeCurrentAttachment(category, index) {
|
||||
const target = category === 'image' ? state.currentImageAttachments : state.currentVideoAttachments
|
||||
const target = category === 'image'
|
||||
? state.currentImageAttachments
|
||||
: (category === 'video' ? state.currentVideoAttachments : state.currentFileAttachments)
|
||||
if (index < 0 || index >= target.length) {
|
||||
return
|
||||
}
|
||||
@@ -840,6 +998,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
|
||||
async function loadDocuments() {
|
||||
setStatus('正在加载文档列表...', '')
|
||||
showLoading('正在加载文档列表...')
|
||||
try {
|
||||
const data = await requestJson('/document/list', {})
|
||||
state.list = data.items || []
|
||||
@@ -847,6 +1006,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '加载列表失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,6 +1025,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
fields.documentRemark.value = ''
|
||||
fields.imageFile.value = ''
|
||||
fields.videoFile.value = ''
|
||||
fields.documentFile.value = ''
|
||||
state.selections.documentTypeSource = ''
|
||||
state.selections.documentTypeValues = []
|
||||
state.selections.documentKeywords = []
|
||||
@@ -875,13 +1037,37 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
|
||||
function enterCreateMode() {
|
||||
clearPendingList(state.pendingImageFiles)
|
||||
clearPendingList(state.pendingVideoFiles)
|
||||
clearPendingList(state.pendingDocumentFiles)
|
||||
state.mode = 'create'
|
||||
state.editingId = ''
|
||||
state.editingSource = null
|
||||
state.currentImageAttachments = []
|
||||
state.currentVideoAttachments = []
|
||||
state.currentFileAttachments = []
|
||||
state.pendingImageFiles = []
|
||||
state.pendingVideoFiles = []
|
||||
state.pendingDocumentFiles = []
|
||||
state.removedAttachmentIds = []
|
||||
resetForm()
|
||||
updateEditorMode()
|
||||
renderAttachmentEditors()
|
||||
}
|
||||
|
||||
function enterIdleMode() {
|
||||
clearPendingList(state.pendingImageFiles)
|
||||
clearPendingList(state.pendingVideoFiles)
|
||||
clearPendingList(state.pendingDocumentFiles)
|
||||
state.mode = 'idle'
|
||||
state.editingId = ''
|
||||
state.editingSource = null
|
||||
state.currentImageAttachments = []
|
||||
state.currentVideoAttachments = []
|
||||
state.currentFileAttachments = []
|
||||
state.pendingImageFiles = []
|
||||
state.pendingVideoFiles = []
|
||||
state.pendingDocumentFiles = []
|
||||
state.removedAttachmentIds = []
|
||||
resetForm()
|
||||
updateEditorMode()
|
||||
@@ -902,6 +1088,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
fields.documentRemark.value = item.document_remark || ''
|
||||
fields.imageFile.value = ''
|
||||
fields.videoFile.value = ''
|
||||
fields.documentFile.value = ''
|
||||
|
||||
const documentTypeParts = splitPipeValue(item.document_type)
|
||||
const firstDocumentType = documentTypeParts.length ? documentTypeParts[0] : ''
|
||||
@@ -936,8 +1123,10 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
state.editingSource = target
|
||||
state.currentImageAttachments = normalizeAttachmentList(target.document_image_attachments, target.document_image_ids, target.document_image_urls)
|
||||
state.currentVideoAttachments = normalizeAttachmentList(target.document_video_attachments, target.document_video_ids, target.document_video_urls)
|
||||
state.currentFileAttachments = normalizeAttachmentList(target.document_file_attachments, target.document_file_ids, target.document_file_urls)
|
||||
state.pendingImageFiles = []
|
||||
state.pendingVideoFiles = []
|
||||
state.pendingDocumentFiles = []
|
||||
state.removedAttachmentIds = []
|
||||
|
||||
fillFormFromItem(target)
|
||||
@@ -947,7 +1136,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function buildMutationPayload(imageAttachments, videoAttachments) {
|
||||
function buildMutationPayload(imageAttachments, videoAttachments, fileAttachments) {
|
||||
const source = state.editingSource || {}
|
||||
return {
|
||||
document_id: state.mode === 'edit' ? state.editingId : '',
|
||||
@@ -962,6 +1151,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
document_content: fields.documentContent.value.trim(),
|
||||
document_image: imageAttachments.map(function (item) { return item.attachments_id }),
|
||||
document_video: videoAttachments.map(function (item) { return item.attachments_id }),
|
||||
document_file: fileAttachments.map(function (item) { return item.attachments_id }),
|
||||
document_relation_model: fields.relationModel.value.trim(),
|
||||
document_keywords: joinPipeValue(state.selections.documentKeywords),
|
||||
document_share_count: typeof source.document_share_count === 'undefined' || source.document_share_count === null ? '' : source.document_share_count,
|
||||
@@ -1011,24 +1201,29 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus(state.mode === 'edit' ? '正在保存文档修改...' : '正在上传附件并创建文档...', '')
|
||||
showLoading(state.mode === 'edit' ? '正在保存文档修改,请稍候...' : '正在上传附件并创建文档,请稍候...')
|
||||
|
||||
const uploadedAttachments = []
|
||||
|
||||
try {
|
||||
const newImageAttachments = await uploadPendingFiles(state.pendingImageFiles, 'image')
|
||||
const newVideoAttachments = await uploadPendingFiles(state.pendingVideoFiles, 'video')
|
||||
const newFileAttachments = await uploadPendingFiles(state.pendingDocumentFiles, 'file')
|
||||
uploadedAttachments.push.apply(uploadedAttachments, newImageAttachments)
|
||||
uploadedAttachments.push.apply(uploadedAttachments, newVideoAttachments)
|
||||
uploadedAttachments.push.apply(uploadedAttachments, newFileAttachments)
|
||||
|
||||
const finalImageAttachments = state.currentImageAttachments.concat(newImageAttachments)
|
||||
const finalVideoAttachments = state.currentVideoAttachments.concat(newVideoAttachments)
|
||||
const payload = buildMutationPayload(finalImageAttachments, finalVideoAttachments)
|
||||
const finalFileAttachments = state.currentFileAttachments.concat(newFileAttachments)
|
||||
const payload = buildMutationPayload(finalImageAttachments, finalVideoAttachments, finalFileAttachments)
|
||||
|
||||
if (state.mode === 'edit') {
|
||||
await requestJson('/document/update', payload)
|
||||
const updated = await requestJson('/document/update', payload)
|
||||
const deleteFailed = await deleteRemovedAttachments()
|
||||
await loadDocuments()
|
||||
enterCreateMode()
|
||||
state.editingId = (updated && updated.document_id) || state.editingId
|
||||
enterEditMode(state.editingId)
|
||||
if (deleteFailed.length) {
|
||||
setStatus('文档已更新,但以下附件删除失败:' + deleteFailed.join(';'), 'error')
|
||||
return
|
||||
@@ -1037,15 +1232,19 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
return
|
||||
}
|
||||
|
||||
await requestJson('/document/create', payload)
|
||||
const created = await requestJson('/document/create', payload)
|
||||
await loadDocuments()
|
||||
enterCreateMode()
|
||||
if (created && created.document_id) {
|
||||
enterEditMode(created.document_id)
|
||||
}
|
||||
setStatus('文档创建成功。', 'success')
|
||||
} catch (err) {
|
||||
if (uploadedAttachments.length) {
|
||||
await cleanupUploadedAttachments(uploadedAttachments)
|
||||
}
|
||||
setStatus(err.message || (state.mode === 'edit' ? '修改文档失败' : '创建文档失败'), 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1056,6 +1255,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
}
|
||||
|
||||
setStatus('正在删除文档...', '')
|
||||
showLoading('正在删除文档,请稍候...')
|
||||
try {
|
||||
await requestJson('/document/delete', { document_id: target })
|
||||
if (state.mode === 'edit' && state.editingId === target) {
|
||||
@@ -1065,6 +1265,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
setStatus('文档删除成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '删除文档失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1072,6 +1274,13 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
window.__editDocument = function (documentId) {
|
||||
enterEditMode(decodeURIComponent(documentId))
|
||||
}
|
||||
window.__previewImage = function (url) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
imageViewerImgEl.src = url
|
||||
imageViewerEl.classList.add('show')
|
||||
}
|
||||
window.__removePendingAttachment = removePendingAttachment
|
||||
window.__removeCurrentAttachment = removeCurrentAttachment
|
||||
|
||||
@@ -1115,8 +1324,24 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
})
|
||||
document.getElementById('submitBtn').addEventListener('click', submitDocument)
|
||||
document.getElementById('cancelEditBtn').addEventListener('click', function () {
|
||||
enterCreateMode()
|
||||
setStatus('已取消编辑。', 'success')
|
||||
enterIdleMode()
|
||||
setStatus('已关闭编辑区。', 'success')
|
||||
})
|
||||
document.getElementById('closeImageViewerBtn').addEventListener('click', function () {
|
||||
imageViewerEl.classList.remove('show')
|
||||
imageViewerImgEl.src = ''
|
||||
})
|
||||
imageViewerEl.addEventListener('click', function (event) {
|
||||
if (event.target === imageViewerEl) {
|
||||
imageViewerEl.classList.remove('show')
|
||||
imageViewerImgEl.src = ''
|
||||
}
|
||||
})
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && imageViewerEl.classList.contains('show')) {
|
||||
imageViewerEl.classList.remove('show')
|
||||
imageViewerImgEl.src = ''
|
||||
}
|
||||
})
|
||||
document.getElementById('resetBtn').addEventListener('click', function () {
|
||||
if (state.mode === 'edit' && state.editingSource) {
|
||||
@@ -1148,10 +1373,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
||||
appendPendingFiles('video', event.target.files)
|
||||
fields.videoFile.value = ''
|
||||
})
|
||||
fields.documentFile.addEventListener('change', function (event) {
|
||||
appendPendingFiles('file', event.target.files)
|
||||
fields.documentFile.value = ''
|
||||
})
|
||||
bindDropzone(imageDropzoneEl, fields.imageFile, 'image')
|
||||
bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
|
||||
bindDropzone(documentFileDropzoneEl, fields.documentFile, 'file')
|
||||
|
||||
enterCreateMode()
|
||||
enterIdleMode()
|
||||
;(async function initPage() {
|
||||
try {
|
||||
await loadDictionaries()
|
||||
|
||||
@@ -39,6 +39,10 @@ routerAdd('GET', '/manage', function (e) {
|
||||
<h2>文档管理</h2>
|
||||
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>SDK 权限管理</h2>
|
||||
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
|
||||
</article>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>
|
||||
|
||||
717
pocket-base/bai_web_pb_hooks/pages/sdk-permission-manage.js
Normal file
717
pocket-base/bai_web_pb_hooks/pages/sdk-permission-manage.js
Normal file
@@ -0,0 +1,717 @@
|
||||
routerAdd('GET', '/manage/sdk-permission-manage', function (e) {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SDK 权限管理</title>
|
||||
<script>
|
||||
(function () {
|
||||
var token = localStorage.getItem('pb_manage_token') || ''
|
||||
var isLoggedIn = localStorage.getItem('pb_manage_logged_in') === '1'
|
||||
if (!token || !isLoggedIn) {
|
||||
window.location.replace('/pb/manage/login')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #eef6ff 0%, #f8fafc 100%); color: #0f172a; }
|
||||
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px 40px; }
|
||||
.panel { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); border-radius: 20px; padding: 18px; }
|
||||
.panel + .panel { margin-top: 14px; }
|
||||
h1, h2, h3 { margin-top: 0; }
|
||||
p { color: #475569; line-height: 1.7; }
|
||||
.actions, .toolbar, .row-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-warning { background: #f59e0b; color: #fff; }
|
||||
.btn-success { background: #16a34a; color: #fff; }
|
||||
.status { margin-top: 12px; min-height: 24px; font-size: 14px; }
|
||||
.status.success { color: #15803d; }
|
||||
.status.error { color: #b91c1c; }
|
||||
.note { padding: 14px 16px; border-radius: 16px; background: #eff6ff; color: #1d4ed8; font-size: 14px; line-height: 1.7; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead { background: #eff6ff; }
|
||||
th, td { padding: 14px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; }
|
||||
th { font-size: 13px; color: #475569; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
input, textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 9px 11px; font-size: 14px; background: #fff; }
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
.empty { text-align: center; padding: 24px 16px; color: #64748b; }
|
||||
.muted { color: #64748b; font-size: 12px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.full { grid-column: 1 / -1; }
|
||||
.rule-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
|
||||
.rule-card { border: 1px solid #dbe3f0; border-radius: 16px; padding: 12px; background: #f8fbff; }
|
||||
.rule-card h4 { margin: 0 0 10px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.rule-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; color: #475569; font-size: 12px; }
|
||||
.rule-meta input[type="checkbox"] { width: auto; margin: 0; }
|
||||
.rule-toggle { display: inline-flex; align-items: center; gap: 6px; color: #0f172a; font-size: 13px; font-weight: 600; }
|
||||
.rule-toggle input[type="checkbox"] { width: auto; margin: 0; }
|
||||
.split { display: grid; grid-template-columns: 1.15fr 1fr; gap: 14px; }
|
||||
.table-wrap { overflow: auto; }
|
||||
.collection-table { table-layout: fixed; }
|
||||
.collection-col { width: 264px; }
|
||||
.rule-col { width: calc(100% - 264px); }
|
||||
.collection-meta { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
||||
.loading-mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(4px); z-index: 9998; }
|
||||
.loading-mask.show { display: flex; }
|
||||
.loading-card { min-width: min(92vw, 360px); padding: 24px 22px; border-radius: 20px; background: rgba(255,255,255,0.98); box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); border: 1px solid #dbe3f0; text-align: center; }
|
||||
.loading-spinner { width: 44px; height: 44px; margin: 0 auto 14px; border-radius: 999px; border: 4px solid #dbeafe; border-top-color: #2563eb; animation: sdkSpin 0.9s linear infinite; }
|
||||
.loading-text { color: #0f172a; font-size: 15px; font-weight: 700; }
|
||||
@keyframes sdkSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@media (max-width: 1100px) {
|
||||
.split, .rule-grid, .grid { grid-template-columns: 1fr; }
|
||||
.collection-col, .rule-col { width: auto; }
|
||||
table, thead, tbody, th, td, tr { display: block; }
|
||||
thead { display: none; }
|
||||
tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
|
||||
td { display: flex; flex-direction: column; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="panel">
|
||||
<h1>SDK 权限管理</h1>
|
||||
<p>这里管理的是 <code>tbl_auth_users</code> 用户通过 PocketBase SDK 直连数据库时的业务权限。<strong>ManagePlatform</strong> 会被视为你的业务管理员,但不会自动变成 PocketBase 原生 <code>_superusers</code>。</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-light" href="/pb/manage">返回主页</a>
|
||||
<button class="btn btn-light" id="refreshBtn" type="button">刷新数据</button>
|
||||
<button class="btn btn-success" id="syncManageBtn" type="button">同步 ManagePlatform 全权限</button>
|
||||
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="note" id="noteBox">加载中...</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>角色管理</h2>
|
||||
<div class="grid">
|
||||
<input id="newRoleName" placeholder="角色名称" />
|
||||
<input id="newRoleCode" placeholder="角色编码,可为空" />
|
||||
<input id="newRoleStatus" type="number" placeholder="状态,默认1" value="1" />
|
||||
<button class="btn btn-primary" id="createRoleBtn" type="button">新增角色</button>
|
||||
<div class="muted">角色 ID 由系统自动生成,页面不显示。</div>
|
||||
<textarea id="newRoleRemark" class="full" placeholder="备注"></textarea>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:16px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>编码</th>
|
||||
<th>状态</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="roleTableBody">
|
||||
<tr><td colspan="5" class="empty">暂无角色。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>用户授权</h2>
|
||||
<div class="toolbar">
|
||||
<input id="userKeywordInput" placeholder="按姓名、手机号、openid、角色搜索" />
|
||||
<button class="btn btn-light" id="searchUserBtn" type="button">查询用户</button>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:16px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>身份类型</th>
|
||||
<th>当前角色</th>
|
||||
<th>授权</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTableBody">
|
||||
<tr><td colspan="5" class="empty">暂无用户。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Collection 直连权限</h2>
|
||||
<div class="toolbar">
|
||||
<select id="permissionRoleSelect"></select>
|
||||
<div class="muted">这里是按“当前配置角色”逐表分配 CRUD 权限。下面依次对应“列表、详情、新增、修改、删除”五种权限,勾选后会立即保存。公开访问或自定义规则的操作不会显示授权勾选框。</div>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:16px;">
|
||||
<table class="collection-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="collection-col">集合</th>
|
||||
<th class="rule-col">当前角色权限</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="collectionTableBody">
|
||||
<tr><td colspan="2" class="empty">暂无集合。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="loading-mask" id="loadingMask">
|
||||
<div class="loading-card">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text" id="loadingText">处理中,请稍候...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '/pb/api/sdk-permission'
|
||||
const tokenKey = 'pb_manage_token'
|
||||
const statusEl = document.getElementById('status')
|
||||
const noteBox = document.getElementById('noteBox')
|
||||
const roleTableBody = document.getElementById('roleTableBody')
|
||||
const userTableBody = document.getElementById('userTableBody')
|
||||
const collectionTableBody = document.getElementById('collectionTableBody')
|
||||
const permissionRoleSelect = document.getElementById('permissionRoleSelect')
|
||||
const loadingMask = document.getElementById('loadingMask')
|
||||
const loadingText = document.getElementById('loadingText')
|
||||
const loadingState = { count: 0 }
|
||||
const state = {
|
||||
roles: [],
|
||||
users: [],
|
||||
collections: [],
|
||||
userKeyword: '',
|
||||
selectedPermissionRoleId: '',
|
||||
}
|
||||
|
||||
function setStatus(message, type) {
|
||||
statusEl.textContent = message || ''
|
||||
statusEl.className = 'status' + (type ? ' ' + type : '')
|
||||
}
|
||||
|
||||
function showLoading(message) {
|
||||
loadingState.count += 1
|
||||
loadingText.textContent = message || '处理中,请稍候...'
|
||||
loadingMask.classList.add('show')
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
loadingState.count = Math.max(0, loadingState.count - 1)
|
||||
if (!loadingState.count) {
|
||||
loadingMask.classList.remove('show')
|
||||
loadingText.textContent = '处理中,请稍候...'
|
||||
}
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(tokenKey) || ''
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function getRoleById(roleId) {
|
||||
const target = String(roleId || '')
|
||||
return state.roles.find(function (role) {
|
||||
return role.role_id === target
|
||||
}) || null
|
||||
}
|
||||
|
||||
function getRoleName(roleId) {
|
||||
const role = getRoleById(roleId)
|
||||
return role ? role.role_name : ''
|
||||
}
|
||||
|
||||
function syncSelectedPermissionRole() {
|
||||
const exists = state.roles.some(function (role) {
|
||||
return role.role_id === state.selectedPermissionRoleId
|
||||
})
|
||||
if (!exists) {
|
||||
state.selectedPermissionRoleId = state.roles.length ? state.roles[0].role_id : ''
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(path, payload) {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
localStorage.removeItem('pb_manage_logged_in')
|
||||
window.location.replace('/pb/manage/login')
|
||||
throw new Error('登录状态已失效,请重新登录')
|
||||
}
|
||||
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + token,
|
||||
},
|
||||
body: JSON.stringify(payload || {}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data || data.code >= 400) {
|
||||
if (res.status === 401 || res.status === 403 || data.code === 401 || data.code === 403) {
|
||||
localStorage.removeItem('pb_manage_token')
|
||||
localStorage.removeItem('pb_manage_logged_in')
|
||||
localStorage.removeItem('pb_manage_login_account')
|
||||
localStorage.removeItem('pb_manage_login_time')
|
||||
window.location.replace('/pb/manage/login')
|
||||
}
|
||||
throw new Error((data && data.msg) || '请求失败')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
function roleOptionsHtml(selectedRoleId) {
|
||||
const current = String(selectedRoleId || '')
|
||||
return ['<option value="">未分配</option>'].concat(state.roles.map(function (role) {
|
||||
return '<option value="' + escapeHtml(role.role_id) + '"' + (current === role.role_id ? ' selected' : '') + '>' + escapeHtml(role.role_name) + '</option>'
|
||||
})).join('')
|
||||
}
|
||||
|
||||
function renderRoles() {
|
||||
if (!state.roles.length) {
|
||||
roleTableBody.innerHTML = '<tr><td colspan="5" class="empty">暂无角色。</td></tr>'
|
||||
return
|
||||
}
|
||||
|
||||
roleTableBody.innerHTML = state.roles.map(function (role) {
|
||||
return '<tr data-role-id="' + escapeHtml(role.role_id) + '">'
|
||||
+ '<td><input data-role-field="role_name" value="' + escapeHtml(role.role_name) + '" /></td>'
|
||||
+ '<td><input data-role-field="role_code" value="' + escapeHtml(role.role_code) + '" /></td>'
|
||||
+ '<td><input data-role-field="role_status" type="number" value="' + escapeHtml(role.role_status) + '" /></td>'
|
||||
+ '<td><textarea data-role-field="role_remark">' + escapeHtml(role.role_remark) + '</textarea></td>'
|
||||
+ '<td><div class="row-actions"><button class="btn btn-light" type="button" onclick="window.__saveRoleRow(\\'' + encodeURIComponent(role.role_id) + '\\')">保存</button><button class="btn btn-danger" type="button" onclick="window.__deleteRoleRow(\\'' + encodeURIComponent(role.role_id) + '\\')">删除</button></div></td>'
|
||||
+ '</tr>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
if (!state.users.length) {
|
||||
userTableBody.innerHTML = '<tr><td colspan="5" class="empty">暂无匹配用户。</td></tr>'
|
||||
return
|
||||
}
|
||||
|
||||
userTableBody.innerHTML = state.users.map(function (user) {
|
||||
const name = user.users_name || '未命名用户'
|
||||
const phone = user.users_phone || '无手机号'
|
||||
const roleName = user.role_name || getRoleName(user.usergroups_id) || '未分配'
|
||||
return '<tr data-user-id="' + escapeHtml(user.pb_id) + '">'
|
||||
+ '<td><div><strong>' + escapeHtml(name) + '</strong></div><div class="muted">' + escapeHtml(phone) + '</div><div class="muted">' + escapeHtml(user.openid) + '</div></td>'
|
||||
+ '<td><div>' + escapeHtml(user.users_idtype || '') + '</div><div class="muted">' + escapeHtml(user.users_type || '') + '</div></td>'
|
||||
+ '<td>' + escapeHtml(roleName) + '</td>'
|
||||
+ '<td><select data-user-role-select="1">' + roleOptionsHtml(user.usergroups_id) + '</select></td>'
|
||||
+ '<td><button class="btn btn-light" type="button" onclick="window.__saveUserRole(\\'' + escapeHtml(user.pb_id) + '\\')">保存角色</button></td>'
|
||||
+ '</tr>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function renderPermissionRoleOptions() {
|
||||
if (!state.roles.length) {
|
||||
permissionRoleSelect.innerHTML = '<option value="">暂无角色</option>'
|
||||
permissionRoleSelect.disabled = true
|
||||
return
|
||||
}
|
||||
|
||||
permissionRoleSelect.disabled = false
|
||||
permissionRoleSelect.innerHTML = state.roles.map(function (role) {
|
||||
return '<option value="' + escapeHtml(role.role_id) + '"' + (role.role_id === state.selectedPermissionRoleId ? ' selected' : '') + '>' + escapeHtml(role.role_name) + '</option>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function getOperationLabel(operation) {
|
||||
const map = {
|
||||
list: '列表',
|
||||
view: '详情',
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
}
|
||||
return map[operation] || operation
|
||||
}
|
||||
|
||||
function getRuleSummary(config, selectedRoleId) {
|
||||
const current = config || { mode: 'locked', includeManagePlatform: false, roles: [], rawExpression: '' }
|
||||
const items = []
|
||||
if (current.mode === 'public') items.push('公开可访问')
|
||||
if (current.mode === 'authenticated') items.push('登录用户可访问')
|
||||
if (current.includeManagePlatform) items.push('含 ManagePlatform')
|
||||
if (current.mode === 'custom') items.push('自定义规则')
|
||||
if (Array.isArray(current.roles) && current.roles.length) {
|
||||
items.push('已分配角色数:' + current.roles.length)
|
||||
}
|
||||
return items.length ? items.join(',') : '当前无额外说明'
|
||||
}
|
||||
|
||||
function canControlRule(config) {
|
||||
const current = config || { mode: 'locked', includeManagePlatform: false, roles: [], rawExpression: '' }
|
||||
return !!state.selectedPermissionRoleId && current.mode !== 'custom' && current.mode !== 'public'
|
||||
}
|
||||
|
||||
function isCollectionFullyChecked(collection) {
|
||||
if (!collection || !collection.parsedRules) {
|
||||
return false
|
||||
}
|
||||
|
||||
const operations = ['list', 'view', 'create', 'update', 'delete']
|
||||
let controllableCount = 0
|
||||
let checkedCount = 0
|
||||
|
||||
for (let i = 0; i < operations.length; i += 1) {
|
||||
const config = collection.parsedRules[operations[i]]
|
||||
if (!canControlRule(config)) {
|
||||
continue
|
||||
}
|
||||
controllableCount += 1
|
||||
if (config && Array.isArray(config.roles) && config.roles.indexOf(state.selectedPermissionRoleId) !== -1) {
|
||||
checkedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return controllableCount > 0 && controllableCount === checkedCount
|
||||
}
|
||||
|
||||
function getCollectionControllableCount(collection) {
|
||||
if (!collection || !collection.parsedRules) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const operations = ['list', 'view', 'create', 'update', 'delete']
|
||||
let controllableCount = 0
|
||||
|
||||
for (let i = 0; i < operations.length; i += 1) {
|
||||
if (canControlRule(collection.parsedRules[operations[i]])) {
|
||||
controllableCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return controllableCount
|
||||
}
|
||||
|
||||
function renderRuleCard(collectionName, operation, config) {
|
||||
const current = config || { mode: 'locked', includeManagePlatform: false, roles: [], rawExpression: '' }
|
||||
const selectedRoleId = state.selectedPermissionRoleId
|
||||
const checked = selectedRoleId && Array.isArray(current.roles) && current.roles.indexOf(selectedRoleId) !== -1
|
||||
const canControl = canControlRule(current)
|
||||
const summary = current.mode === 'public'
|
||||
? '可公开访问'
|
||||
: getRuleSummary(current, selectedRoleId)
|
||||
return '<div class="rule-card" data-collection="' + escapeHtml(collectionName) + '" data-op="' + operation + '">'
|
||||
+ '<h4>' + (canControl ? '<label class="rule-toggle"><input type="checkbox" data-rule-field="allowSelectedRole"' + (checked ? ' checked' : '') + ' /></label>' : '') + '<span>' + getOperationLabel(operation) + '</span></h4>'
|
||||
+ '<div class="muted" style="margin-top:8px;">' + escapeHtml(summary) + '</div>'
|
||||
+ (current.mode === 'custom' ? '<div class="muted" style="margin-top:8px;">当前操作使用 custom 规则,禁止修改</div>' : '')
|
||||
+ '</div>'
|
||||
}
|
||||
|
||||
function renderCollections() {
|
||||
if (!state.collections.length) {
|
||||
collectionTableBody.innerHTML = '<tr><td colspan="2" class="empty">暂无可管理集合。</td></tr>'
|
||||
return
|
||||
}
|
||||
|
||||
collectionTableBody.innerHTML = state.collections.map(function (collection) {
|
||||
const allChecked = isCollectionFullyChecked(collection)
|
||||
const controllableCount = getCollectionControllableCount(collection)
|
||||
return '<tr data-collection-row="' + escapeHtml(collection.name) + '">'
|
||||
+ '<td class="collection-col"><div class="collection-meta"><div><strong>' + escapeHtml(collection.name) + '</strong></div><div class="muted">' + escapeHtml(collection.type) + '</div><label class="rule-toggle"><input type="checkbox" data-rule-field="toggleCollection"' + (allChecked ? ' checked' : '') + (state.selectedPermissionRoleId && controllableCount > 0 ? '' : ' disabled') + ' /><span>全选</span></label></div></td>'
|
||||
+ '<td><div class="rule-grid">'
|
||||
+ renderRuleCard(collection.name, 'list', collection.parsedRules.list)
|
||||
+ renderRuleCard(collection.name, 'view', collection.parsedRules.view)
|
||||
+ renderRuleCard(collection.name, 'create', collection.parsedRules.create)
|
||||
+ renderRuleCard(collection.name, 'update', collection.parsedRules.update)
|
||||
+ renderRuleCard(collection.name, 'delete', collection.parsedRules.delete)
|
||||
+ '</div></td>'
|
||||
+ '</tr>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
async function loadContext() {
|
||||
showLoading('正在加载权限管理数据...')
|
||||
setStatus('正在加载权限管理数据...', '')
|
||||
try {
|
||||
const data = await requestJson('/context', { keyword: state.userKeyword })
|
||||
state.roles = Array.isArray(data.roles) ? data.roles : []
|
||||
state.users = Array.isArray(data.users) ? data.users : []
|
||||
state.collections = Array.isArray(data.collections) ? data.collections : []
|
||||
syncSelectedPermissionRole()
|
||||
noteBox.textContent = data.note || '权限管理说明已加载。'
|
||||
renderRoles()
|
||||
renderUsers()
|
||||
renderPermissionRoleOptions()
|
||||
renderCollections()
|
||||
setStatus('权限管理数据已刷新。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '加载权限管理数据失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleRowPayload(roleId) {
|
||||
const targetId = decodeURIComponent(roleId)
|
||||
const row = roleTableBody.querySelector('[data-role-id="' + targetId.replace(/"/g, '\\"') + '"]')
|
||||
if (!row) {
|
||||
throw new Error('未找到对应角色行')
|
||||
}
|
||||
|
||||
const find = function (fieldName) {
|
||||
const input = row.querySelector('[data-role-field="' + fieldName + '"]')
|
||||
return input ? input.value : ''
|
||||
}
|
||||
|
||||
return {
|
||||
original_role_id: targetId,
|
||||
role_name: find('role_name'),
|
||||
role_code: find('role_code'),
|
||||
role_status: find('role_status'),
|
||||
role_remark: find('role_remark'),
|
||||
}
|
||||
}
|
||||
|
||||
function getRuleBoxConfig(collectionName, operation) {
|
||||
const collection = state.collections.find(function (item) {
|
||||
return item.name === collectionName
|
||||
})
|
||||
const current = collection && collection.parsedRules ? collection.parsedRules[operation] : null
|
||||
const base = current || {
|
||||
mode: 'locked',
|
||||
includeManagePlatform: false,
|
||||
roles: [],
|
||||
rawExpression: '',
|
||||
}
|
||||
if (base.mode === 'custom') {
|
||||
return {
|
||||
mode: 'custom',
|
||||
includeManagePlatform: !!base.includeManagePlatform,
|
||||
roles: Array.isArray(base.roles) ? base.roles.slice() : [],
|
||||
rawExpression: base.rawExpression || '',
|
||||
}
|
||||
}
|
||||
|
||||
const box = collectionTableBody.querySelector('.rule-card[data-collection="' + collectionName.replace(/"/g, '\\"') + '"][data-op="' + operation + '"]')
|
||||
const allowEl = box ? box.querySelector('[data-rule-field="allowSelectedRole"]') : null
|
||||
const roles = Array.isArray(base.roles) ? base.roles.slice() : []
|
||||
const selectedRoleId = state.selectedPermissionRoleId
|
||||
const nextRoles = roles.filter(function (roleId) {
|
||||
return roleId !== selectedRoleId
|
||||
})
|
||||
|
||||
if (selectedRoleId && allowEl && allowEl.checked && nextRoles.indexOf(selectedRoleId) === -1) {
|
||||
nextRoles.push(selectedRoleId)
|
||||
}
|
||||
|
||||
let nextMode = base.mode
|
||||
if (nextMode === 'locked' && nextRoles.length) {
|
||||
nextMode = 'roleBased'
|
||||
} else if (nextMode === 'roleBased' && !nextRoles.length && !base.includeManagePlatform) {
|
||||
nextMode = 'locked'
|
||||
}
|
||||
|
||||
return {
|
||||
mode: nextMode,
|
||||
includeManagePlatform: !!base.includeManagePlatform,
|
||||
roles: nextRoles,
|
||||
rawExpression: base.rawExpression || '',
|
||||
}
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const payload = {
|
||||
role_name: document.getElementById('newRoleName').value.trim(),
|
||||
role_code: document.getElementById('newRoleCode').value.trim(),
|
||||
role_status: document.getElementById('newRoleStatus').value.trim() || '1',
|
||||
role_remark: document.getElementById('newRoleRemark').value.trim(),
|
||||
}
|
||||
|
||||
showLoading('正在新增角色...')
|
||||
try {
|
||||
await requestJson('/role-save', payload)
|
||||
document.getElementById('newRoleName').value = ''
|
||||
document.getElementById('newRoleCode').value = ''
|
||||
document.getElementById('newRoleStatus').value = '1'
|
||||
document.getElementById('newRoleRemark').value = ''
|
||||
await loadContext()
|
||||
setStatus('角色新增成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '新增角色失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRoleRow(roleId) {
|
||||
showLoading('正在保存角色...')
|
||||
try {
|
||||
await requestJson('/role-save', getRoleRowPayload(roleId))
|
||||
await loadContext()
|
||||
setStatus('角色保存成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '保存角色失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRoleRow(roleId) {
|
||||
const targetId = decodeURIComponent(roleId)
|
||||
if (!window.confirm('确认删除角色「' + targetId + '」吗?这会清空绑定该角色的用户,并从已解析的集合规则中移除它。')) {
|
||||
return
|
||||
}
|
||||
|
||||
showLoading('正在删除角色...')
|
||||
try {
|
||||
await requestJson('/role-delete', { role_id: targetId })
|
||||
await loadContext()
|
||||
setStatus('角色删除成功。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '删除角色失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUserRole(pbId) {
|
||||
const row = userTableBody.querySelector('[data-user-id="' + pbId.replace(/"/g, '\\"') + '"]')
|
||||
const select = row ? row.querySelector('[data-user-role-select="1"]') : null
|
||||
const payload = {
|
||||
pb_id: pbId,
|
||||
usergroups_id: select ? select.value : '',
|
||||
}
|
||||
|
||||
showLoading('正在保存用户角色...')
|
||||
try {
|
||||
await requestJson('/user-role-update', payload)
|
||||
await loadContext()
|
||||
setStatus('用户角色已更新。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '更新用户角色失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCollectionRules(collectionName) {
|
||||
const targetName = decodeURIComponent(collectionName)
|
||||
if (!state.selectedPermissionRoleId) {
|
||||
setStatus('请先选择一个要配置权限的角色。', 'error')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
collection_name: targetName,
|
||||
rules: {
|
||||
list: getRuleBoxConfig(targetName, 'list'),
|
||||
view: getRuleBoxConfig(targetName, 'view'),
|
||||
create: getRuleBoxConfig(targetName, 'create'),
|
||||
update: getRuleBoxConfig(targetName, 'update'),
|
||||
delete: getRuleBoxConfig(targetName, 'delete'),
|
||||
},
|
||||
}
|
||||
|
||||
showLoading('正在同步集合权限...')
|
||||
try {
|
||||
await requestJson('/collection-save', payload)
|
||||
await loadContext()
|
||||
setStatus('已保存角色「' + (getRoleName(state.selectedPermissionRoleId) || '未命名角色') + '」在集合「' + targetName + '」上的权限。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '保存集合权限失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
async function syncManagePlatform() {
|
||||
if (!window.confirm('确认将 ManagePlatform 同步为所有业务集合的全权限吗?这不会创建 _superusers,但会为业务表开放全部 CRUD。')) {
|
||||
return
|
||||
}
|
||||
|
||||
showLoading('正在同步 ManagePlatform 全权限...')
|
||||
try {
|
||||
const data = await requestJson('/manageplatform-sync', {})
|
||||
await loadContext()
|
||||
setStatus('已同步 ManagePlatform 全权限,共处理 ' + String((data && data.count) || 0) + ' 个集合。', 'success')
|
||||
} catch (err) {
|
||||
setStatus(err.message || '同步 ManagePlatform 全权限失败', 'error')
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
window.__saveRoleRow = saveRoleRow
|
||||
window.__deleteRoleRow = deleteRoleRow
|
||||
window.__saveUserRole = saveUserRole
|
||||
window.__saveCollectionRules = saveCollectionRules
|
||||
|
||||
document.getElementById('createRoleBtn').addEventListener('click', createRole)
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadContext)
|
||||
document.getElementById('syncManageBtn').addEventListener('click', syncManagePlatform)
|
||||
permissionRoleSelect.addEventListener('change', function () {
|
||||
state.selectedPermissionRoleId = permissionRoleSelect.value || ''
|
||||
renderPermissionRoleOptions()
|
||||
renderCollections()
|
||||
})
|
||||
collectionTableBody.addEventListener('change', function (event) {
|
||||
const target = event.target
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const field = target.getAttribute('data-rule-field')
|
||||
if (field === 'allowSelectedRole') {
|
||||
const box = target.closest('.rule-card')
|
||||
if (!box) {
|
||||
return
|
||||
}
|
||||
const collectionName = box.getAttribute('data-collection') || ''
|
||||
saveCollectionRules(collectionName)
|
||||
return
|
||||
}
|
||||
|
||||
if (field === 'toggleCollection') {
|
||||
const row = target.closest('[data-collection-row]')
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
const checkboxes = row.querySelectorAll('[data-rule-field="allowSelectedRole"]')
|
||||
for (let i = 0; i < checkboxes.length; i += 1) {
|
||||
if (!checkboxes[i].disabled) {
|
||||
checkboxes[i].checked = !!target.checked
|
||||
}
|
||||
}
|
||||
const collectionName = row.getAttribute('data-collection-row') || ''
|
||||
saveCollectionRules(collectionName)
|
||||
}
|
||||
})
|
||||
document.getElementById('searchUserBtn').addEventListener('click', function () {
|
||||
state.userKeyword = document.getElementById('userKeywordInput').value.trim()
|
||||
loadContext()
|
||||
})
|
||||
document.getElementById('logoutBtn').addEventListener('click', function () {
|
||||
localStorage.removeItem('pb_manage_token')
|
||||
localStorage.removeItem('pb_manage_logged_in')
|
||||
localStorage.removeItem('pb_manage_login_account')
|
||||
localStorage.removeItem('pb_manage_login_time')
|
||||
window.location.replace('/pb/manage/login')
|
||||
})
|
||||
|
||||
loadContext()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return e.html(200, html)
|
||||
})
|
||||
@@ -57,6 +57,28 @@
|
||||
- 已将 schema 脚本中的 `user_id` 改为非必填。
|
||||
- 其余业务字段保持非必填。
|
||||
|
||||
### 4. 用户图片字段统一改为附件 ID 语义
|
||||
|
||||
- `users_picture`
|
||||
- `users_id_pic_a`
|
||||
- `users_id_pic_b`
|
||||
- `users_title_picture`
|
||||
|
||||
以上字段已统一改为保存 `tbl_attachments.attachments_id`,不再直接保存 PocketBase `file` 字段或外部图片 URL。
|
||||
|
||||
查询用户信息时,hooks 会自动联查 `tbl_attachments` 并补充:
|
||||
|
||||
- `users_picture_url`
|
||||
- `users_id_pic_a_url`
|
||||
- `users_id_pic_b_url`
|
||||
- `users_title_picture_url`
|
||||
|
||||
说明:
|
||||
|
||||
- `tbl_attachments` 仍由附件表保存实际文件本体;
|
||||
- 业务表仅负责保存附件 ID;
|
||||
- hooks 中原有 `ManagePlatform` 访问限制保持不变。
|
||||
|
||||
---
|
||||
|
||||
## 三、查询与排序修复
|
||||
@@ -238,7 +260,7 @@
|
||||
- 自动写入 `attachments_owner = 当前用户 openid`
|
||||
- `POST /pb/api/attachment/delete`
|
||||
- 按 `attachments_id` 真删除附件
|
||||
- 若该附件已被 `tbl_document.document_image` 或 `document_video` 中的任一附件列表引用,则拒绝删除
|
||||
- 若该附件已被 `tbl_document.document_image`、`document_video` 或 `document_file` 中的任一附件列表引用,则拒绝删除
|
||||
|
||||
说明:
|
||||
|
||||
@@ -258,8 +280,10 @@
|
||||
- 额外补充:
|
||||
- `document_image_urls`
|
||||
- `document_video_urls`
|
||||
- `document_file_urls`
|
||||
- `document_image_attachments`
|
||||
- `document_video_attachments`
|
||||
- `document_file_attachments`
|
||||
- `POST /pb/api/document/detail`
|
||||
- 按 `document_id` 查询单条文档
|
||||
- 返回与附件表联动解析后的多文件流链接
|
||||
@@ -267,7 +291,7 @@
|
||||
- 新增文档
|
||||
- `document_id` 可不传,由服务端自动生成
|
||||
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
||||
- `document_image`、`document_video` 支持传入多个已存在的 `attachments_id`
|
||||
- `document_image`、`document_video`、`document_file` 支持传入多个已存在的 `attachments_id`
|
||||
- `document_type` 前端从单个字典来源中多选枚举值,最终按 `system_dict_id@dict_word_enum|...` 保存
|
||||
- `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一从固定字典多选并按 `|` 保存
|
||||
- 其中 `document_product_categories` 改为从 `文档-产品关联文档` 读取,`document_application_scenarios` 改为从 `文档-筛选依据` 读取,`document_hotel_type` 改为从 `文档-适用场景` 读取
|
||||
@@ -285,7 +309,7 @@
|
||||
|
||||
说明:
|
||||
|
||||
- `document_image`、`document_video` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。
|
||||
- `document_image`、`document_video`、`document_file` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。
|
||||
- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接数组。
|
||||
- `document_owner` 语义为“上传者 openid”。
|
||||
|
||||
@@ -337,8 +361,8 @@
|
||||
- 返回主页
|
||||
- 文档管理页支持:
|
||||
- 先上传附件到 `tbl_attachments`
|
||||
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video`
|
||||
- 图片和视频都支持多选上传
|
||||
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video` / `document_file`
|
||||
- 图片、视频、文件都支持多选上传
|
||||
- 新增文档
|
||||
- 编辑已有文档并回显多图片、多视频
|
||||
- 从文档中移除附件并在保存后删除对应附件记录
|
||||
|
||||
@@ -130,6 +130,28 @@ components:
|
||||
type: string
|
||||
users_picture:
|
||||
type: string
|
||||
description: "用户头像附件的 `attachments_id`"
|
||||
users_picture_url:
|
||||
type: string
|
||||
description: "根据 `users_picture -> tbl_attachments` 自动解析出的头像文件流链接"
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: "证件正面附件的 `attachments_id`"
|
||||
users_id_pic_a_url:
|
||||
type: string
|
||||
description: "根据 `users_id_pic_a -> tbl_attachments` 自动解析出的文件流链接"
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: "证件反面附件的 `attachments_id`"
|
||||
users_id_pic_b_url:
|
||||
type: string
|
||||
description: "根据 `users_id_pic_b -> tbl_attachments` 自动解析出的文件流链接"
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: "资质附件的 `attachments_id`"
|
||||
users_title_picture_url:
|
||||
type: string
|
||||
description: "根据 `users_title_picture -> tbl_attachments` 自动解析出的文件流链接"
|
||||
openid:
|
||||
type: string
|
||||
description: "全平台统一身份标识;微信用户为微信 openid,平台用户为服务端生成的 GUID"
|
||||
@@ -190,6 +212,13 @@ components:
|
||||
users_auth_type: 0
|
||||
users_type: 注册用户
|
||||
users_picture: ''
|
||||
users_picture_url: ''
|
||||
users_id_pic_a: ''
|
||||
users_id_pic_a_url: ''
|
||||
users_id_pic_b: ''
|
||||
users_id_pic_b_url: ''
|
||||
users_title_picture: ''
|
||||
users_title_picture_url: ''
|
||||
openid: app_momo
|
||||
company_id: ''
|
||||
users_parent_id: ''
|
||||
@@ -221,7 +250,17 @@ components:
|
||||
example: 2b7d9f2e3c4a5b6d7e8f
|
||||
users_picture:
|
||||
type: string
|
||||
example: https://example.com/avatar.png
|
||||
description: "用户头像附件的 `attachments_id`"
|
||||
example: ATT-1743123456789-abc123
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: "可选。证件正面附件的 `attachments_id`"
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: "可选。证件反面附件的 `attachments_id`"
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: "可选。资质附件的 `attachments_id`"
|
||||
WechatProfileResponseData:
|
||||
type: object
|
||||
properties:
|
||||
@@ -249,9 +288,19 @@ components:
|
||||
example: 12345678
|
||||
users_picture:
|
||||
type: string
|
||||
example: https://example.com/avatar.png
|
||||
description: "用户头像附件的 `attachments_id`"
|
||||
example: ATT-1743123456789-abc123
|
||||
users_id_number:
|
||||
type: string
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: "可选。证件正面附件的 `attachments_id`"
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: "可选。证件反面附件的 `attachments_id`"
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: "可选。资质附件的 `attachments_id`"
|
||||
users_level:
|
||||
type: string
|
||||
users_type:
|
||||
@@ -537,6 +586,30 @@ components:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/AttachmentRecord'
|
||||
- type: 'null'
|
||||
document_file:
|
||||
type: string
|
||||
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
|
||||
document_file_ids:
|
||||
type: array
|
||||
description: "`document_file` 解析后的附件 id 列表"
|
||||
items:
|
||||
type: string
|
||||
document_file_urls:
|
||||
type: array
|
||||
description: "根据 `document_file -> tbl_attachments` 自动解析出的文件流链接列表"
|
||||
items:
|
||||
type: string
|
||||
document_file_url:
|
||||
type: string
|
||||
description: "兼容字段,返回第一个文件的文件流链接"
|
||||
document_file_attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttachmentRecord'
|
||||
document_file_attachment:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/AttachmentRecord'
|
||||
- type: 'null'
|
||||
document_owner:
|
||||
type: string
|
||||
description: "上传者 openid"
|
||||
@@ -641,6 +714,14 @@ components:
|
||||
items:
|
||||
type: string
|
||||
description: "视频附件 id 列表;支持数组或 `|` 分隔字符串"
|
||||
document_file:
|
||||
oneOf:
|
||||
- type: string
|
||||
description: "多个文件附件 id 使用 `|` 分隔"
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
description: "文件附件 id 列表;支持数组或 `|` 分隔字符串"
|
||||
document_relation_model:
|
||||
type: string
|
||||
document_keywords:
|
||||
@@ -1144,7 +1225,7 @@ paths:
|
||||
summary: 删除附件
|
||||
description: |
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除。
|
||||
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image`、`document_video` 或 `document_file` 引用,则拒绝删除。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1185,8 +1266,8 @@ paths:
|
||||
description: |
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
|
||||
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments`,
|
||||
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。
|
||||
返回结果会自动根据 `document_image`、`document_video`、`document_file` 中的多个 `attachments_id` 关联 `tbl_attachments`,
|
||||
额外补充 `document_image_urls`、`document_video_urls`、`document_file_urls` 以及对应附件对象数组。
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -1259,7 +1340,7 @@ paths:
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
`document_id` 可选;未传时服务端自动生成。
|
||||
`document_title`、`document_type` 为必填;其余字段均允许为空。
|
||||
`document_image`、`document_video` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
|
||||
`document_image`、`document_video`、`document_file` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
|
||||
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1297,7 +1378,7 @@ paths:
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
按 `document_id` 定位现有文档并更新。
|
||||
`document_title`、`document_type` 为必填;其余字段均允许为空。
|
||||
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。
|
||||
若传入 `document_image`、`document_video`、`document_file`,则支持多个 `attachments_id`,并会逐一校验是否存在。
|
||||
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
449
pocket-base/spec/openapi-miniapp-company.yaml
Normal file
449
pocket-base/spec/openapi-miniapp-company.yaml
Normal file
@@ -0,0 +1,449 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: PocketBase MiniApp Company API
|
||||
version: 1.0.0
|
||||
summary: 小程序端通过 PocketBase JS SDK 直连 tbl_company 的基础 CRUD 文档
|
||||
description: >-
|
||||
本文档面向小程序端直接使用 PocketBase JS SDK / REST API 访问 `tbl_company`。
|
||||
本文档统一以 PocketBase 原生记录主键 `id` 作为唯一识别键。
|
||||
`company_id` 保留为普通业务字段,可用于展示、筛选和业务关联,但不再作为 CRUD 的唯一键。
|
||||
license:
|
||||
name: Proprietary
|
||||
identifier: LicenseRef-Proprietary
|
||||
servers:
|
||||
- url: https://bai-api.blv-oa.com/pb
|
||||
description: 线上 PocketBase 服务
|
||||
tags:
|
||||
- name: Company
|
||||
description: tbl_company 公司信息基础 CRUD
|
||||
security:
|
||||
- pocketbaseAuth: []
|
||||
paths:
|
||||
/api/collections/tbl_company/records:
|
||||
get:
|
||||
tags: [Company]
|
||||
operationId: listCompanyRecords
|
||||
summary: 查询公司列表
|
||||
description: >-
|
||||
使用 PocketBase 原生 records list/search 接口查询 `tbl_company`。
|
||||
支持三种常见模式:
|
||||
1. 全表查询:不传 `filter`;
|
||||
2. 精确查询:`filter=id="q1w2e3r4t5y6u7i"`;
|
||||
3. 模糊查询:`filter=(company_name~"华住" || company_usci~"9131" || company_entity~"张三")`。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Page'
|
||||
- $ref: '#/components/parameters/PerPage'
|
||||
- $ref: '#/components/parameters/Sort'
|
||||
- $ref: '#/components/parameters/Filter'
|
||||
- $ref: '#/components/parameters/Fields'
|
||||
- $ref: '#/components/parameters/SkipTotal'
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompanyListResponse'
|
||||
examples:
|
||||
all:
|
||||
summary: 全表查询
|
||||
value:
|
||||
page: 1
|
||||
perPage: 30
|
||||
totalItems: 2
|
||||
totalPages: 1
|
||||
items:
|
||||
- id: q1w2e3r4t5y6u7i
|
||||
collectionId: pbc_company_demo
|
||||
collectionName: tbl_company
|
||||
created: '2026-03-27 10:00:00.000Z'
|
||||
updated: '2026-03-27 10:00:00.000Z'
|
||||
company_id: C10001
|
||||
company_name: 宝镜科技
|
||||
company_type: 渠道商
|
||||
company_entity: 张三
|
||||
company_usci: '91310000123456789A'
|
||||
company_nationality: 中国
|
||||
company_province: 上海
|
||||
company_city: 上海
|
||||
company_postalcode: '200000'
|
||||
company_add: 上海市浦东新区XX路1号
|
||||
company_status: 有效
|
||||
company_level: A
|
||||
company_remark: ''
|
||||
exact:
|
||||
summary: 按 id 精确查询
|
||||
value:
|
||||
page: 1
|
||||
perPage: 1
|
||||
totalItems: 1
|
||||
totalPages: 1
|
||||
items:
|
||||
- id: q1w2e3r4t5y6u7i
|
||||
collectionId: pbc_company_demo
|
||||
collectionName: tbl_company
|
||||
created: '2026-03-27 10:00:00.000Z'
|
||||
updated: '2026-03-27 10:00:00.000Z'
|
||||
company_id: C10001
|
||||
company_name: 宝镜科技
|
||||
company_type: 渠道商
|
||||
company_entity: 张三
|
||||
company_usci: '91310000123456789A'
|
||||
company_nationality: 中国
|
||||
company_province: 上海
|
||||
company_city: 上海
|
||||
company_postalcode: '200000'
|
||||
company_add: 上海市浦东新区XX路1号
|
||||
company_status: 有效
|
||||
company_level: A
|
||||
company_remark: ''
|
||||
'400':
|
||||
description: 过滤表达式或查询参数不合法
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'403':
|
||||
description: 当前调用方没有 list 权限
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
post:
|
||||
tags: [Company]
|
||||
operationId: createCompanyRecord
|
||||
summary: 新增公司
|
||||
description: >-
|
||||
创建一条 `tbl_company` 记录。当前文档以 `id` 作为记录唯一识别键,
|
||||
新建成功后由 PocketBase 自动生成 `id`;根据当前项目建表脚本,`company_id` 仍是必填业务字段,但不再作为 CRUD 唯一键。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompanyCreateRequest'
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
company_id: C10001
|
||||
company_name: 宝镜科技
|
||||
company_type: 渠道商
|
||||
company_entity: 张三
|
||||
company_usci: '91310000123456789A'
|
||||
company_nationality: 中国
|
||||
company_province: 上海
|
||||
company_city: 上海
|
||||
company_postalcode: '200000'
|
||||
company_add: 上海市浦东新区XX路1号
|
||||
company_status: 有效
|
||||
company_level: A
|
||||
company_remark: 首次创建
|
||||
responses:
|
||||
'200':
|
||||
description: 创建成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompanyRecord'
|
||||
'400':
|
||||
description: 校验失败,例如字段类型不合法或违反当前集合约束
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'403':
|
||||
description: 当前调用方没有 create 权限
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'404':
|
||||
description: 集合不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
/api/collections/tbl_company/records/{recordId}:
|
||||
get:
|
||||
tags: [Company]
|
||||
operationId: getCompanyRecordByRecordId
|
||||
summary: 按 PocketBase 记录 id 查询公司
|
||||
description: >-
|
||||
这是 PocketBase 原生单条查询接口,路径参数必须传记录主键 `id`。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RecordId'
|
||||
- $ref: '#/components/parameters/Fields'
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompanyRecord'
|
||||
'403':
|
||||
description: 当前调用方没有 view 权限
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'404':
|
||||
description: 记录不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
patch:
|
||||
tags: [Company]
|
||||
operationId: updateCompanyRecordByRecordId
|
||||
summary: 按 PocketBase 记录 id 更新公司
|
||||
description: >-
|
||||
这是 PocketBase 原生更新接口,路径参数统一使用记录主键 `id`。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RecordId'
|
||||
- $ref: '#/components/parameters/Fields'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompanyUpdateRequest'
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
company_name: 宝镜科技(更新)
|
||||
company_status: 有效
|
||||
company_level: S
|
||||
company_remark: 已更新基础资料
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompanyRecord'
|
||||
'400':
|
||||
description: 更新参数不合法
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'403':
|
||||
description: 当前调用方没有 update 权限
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'404':
|
||||
description: 记录不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
delete:
|
||||
tags: [Company]
|
||||
operationId: deleteCompanyRecordByRecordId
|
||||
summary: 按 PocketBase 记录 id 删除公司
|
||||
description: >-
|
||||
这是 PocketBase 原生删除接口,路径参数统一使用记录主键 `id`。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RecordId'
|
||||
responses:
|
||||
'204':
|
||||
description: 删除成功
|
||||
'400':
|
||||
description: 删除失败,例如仍被必填 relation 引用
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'403':
|
||||
description: 当前调用方没有 delete 权限
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
'404':
|
||||
description: 记录不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PocketBaseError'
|
||||
components:
|
||||
securitySchemes:
|
||||
pocketbaseAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: Authorization
|
||||
description: PocketBase 认证 token。使用 JS SDK 时通常由 `pb.authStore` 自动附带。
|
||||
parameters:
|
||||
Page:
|
||||
name: page
|
||||
in: query
|
||||
description: 页码,默认 1
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
PerPage:
|
||||
name: perPage
|
||||
in: query
|
||||
description: 每页返回条数,默认 30
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
Sort:
|
||||
name: sort
|
||||
in: query
|
||||
description: 排序字段,例如 `-created,+company_name`
|
||||
schema:
|
||||
type: string
|
||||
Filter:
|
||||
name: filter
|
||||
in: query
|
||||
description: >-
|
||||
PocketBase 过滤表达式。
|
||||
精确查询示例:`id="q1w2e3r4t5y6u7i"`;
|
||||
模糊查询示例:`(company_name~"宝镜" || company_usci~"9131" || company_entity~"张三")`
|
||||
schema:
|
||||
type: string
|
||||
Fields:
|
||||
name: fields
|
||||
in: query
|
||||
description: 逗号分隔的返回字段列表,例如 `id,company_id,company_name`
|
||||
schema:
|
||||
type: string
|
||||
SkipTotal:
|
||||
name: skipTotal
|
||||
in: query
|
||||
description: 是否跳过 totalItems/totalPages 统计
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
RecordId:
|
||||
name: recordId
|
||||
in: path
|
||||
required: true
|
||||
description: PocketBase 记录主键 id
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
CompanyBase:
|
||||
type: object
|
||||
properties:
|
||||
company_id:
|
||||
type: string
|
||||
description: 公司业务编号字段,不再作为 CRUD 唯一键
|
||||
company_name:
|
||||
type: string
|
||||
description: 公司名称
|
||||
company_type:
|
||||
type: string
|
||||
description: 公司类型
|
||||
company_entity:
|
||||
type: string
|
||||
description: 公司法人
|
||||
company_usci:
|
||||
type: string
|
||||
description: 统一社会信用代码
|
||||
company_nationality:
|
||||
type: string
|
||||
description: 国家
|
||||
company_province:
|
||||
type: string
|
||||
description: 省份
|
||||
company_city:
|
||||
type: string
|
||||
description: 城市
|
||||
company_postalcode:
|
||||
type: string
|
||||
description: 邮编
|
||||
company_add:
|
||||
type: string
|
||||
description: 地址
|
||||
company_status:
|
||||
type: string
|
||||
description: 公司状态
|
||||
company_level:
|
||||
type: string
|
||||
description: 公司等级
|
||||
company_remark:
|
||||
type: string
|
||||
description: 备注
|
||||
CompanyCreateRequest:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/CompanyBase'
|
||||
- type: object
|
||||
required: [company_id]
|
||||
CompanyUpdateRequest:
|
||||
type: object
|
||||
description: >-
|
||||
更新时可只传需要修改的字段;记录定位统一依赖路径参数 `id`。
|
||||
properties:
|
||||
company_id:
|
||||
type: string
|
||||
company_name:
|
||||
type: string
|
||||
company_type:
|
||||
type: string
|
||||
company_entity:
|
||||
type: string
|
||||
company_usci:
|
||||
type: string
|
||||
company_nationality:
|
||||
type: string
|
||||
company_province:
|
||||
type: string
|
||||
company_city:
|
||||
type: string
|
||||
company_postalcode:
|
||||
type: string
|
||||
company_add:
|
||||
type: string
|
||||
company_status:
|
||||
type: string
|
||||
company_level:
|
||||
type: string
|
||||
company_remark:
|
||||
type: string
|
||||
CompanyRecord:
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: PocketBase 记录主键 id
|
||||
collectionId:
|
||||
type: string
|
||||
collectionName:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
updated:
|
||||
type: string
|
||||
- $ref: '#/components/schemas/CompanyBase'
|
||||
CompanyListResponse:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
perPage:
|
||||
type: integer
|
||||
totalItems:
|
||||
type: integer
|
||||
totalPages:
|
||||
type: integer
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CompanyRecord'
|
||||
PocketBaseError:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@@ -297,6 +297,28 @@ components:
|
||||
type: string
|
||||
users_picture:
|
||||
type: string
|
||||
description: 用户头像附件的 `attachments_id`
|
||||
users_picture_url:
|
||||
type: string
|
||||
description: 根据 `users_picture -> tbl_attachments` 自动解析出的头像文件流链接
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: 证件正面附件的 `attachments_id`
|
||||
users_id_pic_a_url:
|
||||
type: string
|
||||
description: 根据 `users_id_pic_a -> tbl_attachments` 自动解析出的文件流链接
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: 证件反面附件的 `attachments_id`
|
||||
users_id_pic_b_url:
|
||||
type: string
|
||||
description: 根据 `users_id_pic_b -> tbl_attachments` 自动解析出的文件流链接
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: 资质附件的 `attachments_id`
|
||||
users_title_picture_url:
|
||||
type: string
|
||||
description: 根据 `users_title_picture -> tbl_attachments` 自动解析出的文件流链接
|
||||
openid:
|
||||
type: string
|
||||
description: 全平台统一身份标识
|
||||
@@ -338,7 +360,17 @@ components:
|
||||
example: 2b7d9f2e3c4a5b6d7e8f
|
||||
users_picture:
|
||||
type: string
|
||||
example: https://example.com/avatar.png
|
||||
description: 用户头像附件的 `attachments_id`
|
||||
example: ATT-1743123456789-abc123
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: 可选。证件正面附件的 `attachments_id`
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: 可选。证件反面附件的 `attachments_id`
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: 可选。资质附件的 `attachments_id`
|
||||
SystemRefreshTokenRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -120,6 +120,28 @@ components:
|
||||
type: string
|
||||
users_picture:
|
||||
type: string
|
||||
description: 用户头像附件的 `attachments_id`
|
||||
users_picture_url:
|
||||
type: string
|
||||
description: 根据 `users_picture -> tbl_attachments` 自动解析出的头像文件流链接
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: 证件正面附件的 `attachments_id`
|
||||
users_id_pic_a_url:
|
||||
type: string
|
||||
description: 根据 `users_id_pic_a -> tbl_attachments` 自动解析出的文件流链接
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: 证件反面附件的 `attachments_id`
|
||||
users_id_pic_b_url:
|
||||
type: string
|
||||
description: 根据 `users_id_pic_b -> tbl_attachments` 自动解析出的文件流链接
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: 资质附件的 `attachments_id`
|
||||
users_title_picture_url:
|
||||
type: string
|
||||
description: 根据 `users_title_picture -> tbl_attachments` 自动解析出的文件流链接
|
||||
openid:
|
||||
type: string
|
||||
description: 全平台统一身份标识;微信用户为微信 openid,平台用户为服务端生成的 GUID
|
||||
@@ -180,6 +202,13 @@ components:
|
||||
users_auth_type: 0
|
||||
users_type: 注册用户
|
||||
users_picture: ''
|
||||
users_picture_url: ''
|
||||
users_id_pic_a: ''
|
||||
users_id_pic_a_url: ''
|
||||
users_id_pic_b: ''
|
||||
users_id_pic_b_url: ''
|
||||
users_title_picture: ''
|
||||
users_title_picture_url: ''
|
||||
openid: app_momo
|
||||
company_id: ''
|
||||
users_parent_id: ''
|
||||
@@ -211,7 +240,17 @@ components:
|
||||
example: 2b7d9f2e3c4a5b6d7e8f
|
||||
users_picture:
|
||||
type: string
|
||||
example: https://example.com/avatar.png
|
||||
description: 用户头像附件的 `attachments_id`
|
||||
example: ATT-1743123456789-abc123
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: 可选。证件正面附件的 `attachments_id`
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: 可选。证件反面附件的 `attachments_id`
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: 可选。资质附件的 `attachments_id`
|
||||
WechatProfileResponseData:
|
||||
type: object
|
||||
properties:
|
||||
@@ -239,9 +278,19 @@ components:
|
||||
example: 12345678
|
||||
users_picture:
|
||||
type: string
|
||||
example: https://example.com/avatar.png
|
||||
description: 用户头像附件的 `attachments_id`
|
||||
example: ATT-1743123456789-abc123
|
||||
users_id_number:
|
||||
type: string
|
||||
users_id_pic_a:
|
||||
type: string
|
||||
description: 可选。证件正面附件的 `attachments_id`
|
||||
users_id_pic_b:
|
||||
type: string
|
||||
description: 可选。证件反面附件的 `attachments_id`
|
||||
users_title_picture:
|
||||
type: string
|
||||
description: 可选。资质附件的 `attachments_id`
|
||||
users_level:
|
||||
type: string
|
||||
users_type:
|
||||
@@ -480,52 +529,76 @@ components:
|
||||
type: string
|
||||
document_image:
|
||||
type: string
|
||||
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存
|
||||
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
|
||||
document_image_ids:
|
||||
type: array
|
||||
description: `document_image` 解析后的附件 id 列表
|
||||
description: "`document_image` 解析后的附件 id 列表"
|
||||
items:
|
||||
type: string
|
||||
document_image_urls:
|
||||
type: array
|
||||
description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表
|
||||
description: "根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表"
|
||||
items:
|
||||
type: string
|
||||
document_image_url:
|
||||
type: string
|
||||
description: 兼容字段,返回第一张图片的文件流链接
|
||||
description: "兼容字段,返回第一张图片的文件流链接"
|
||||
document_image_attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttachmentRecord'
|
||||
document_image_attachment:
|
||||
allOf:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/AttachmentRecord'
|
||||
nullable: true
|
||||
- type: 'null'
|
||||
document_video:
|
||||
type: string
|
||||
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存
|
||||
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
|
||||
document_video_ids:
|
||||
type: array
|
||||
description: `document_video` 解析后的附件 id 列表
|
||||
description: "`document_video` 解析后的附件 id 列表"
|
||||
items:
|
||||
type: string
|
||||
document_video_urls:
|
||||
type: array
|
||||
description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表
|
||||
description: "根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表"
|
||||
items:
|
||||
type: string
|
||||
document_video_url:
|
||||
type: string
|
||||
description: 兼容字段,返回第一个视频的文件流链接
|
||||
description: "兼容字段,返回第一个视频的文件流链接"
|
||||
document_video_attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttachmentRecord'
|
||||
document_video_attachment:
|
||||
allOf:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/AttachmentRecord'
|
||||
nullable: true
|
||||
- type: 'null'
|
||||
document_file:
|
||||
type: string
|
||||
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
|
||||
document_file_ids:
|
||||
type: array
|
||||
description: "`document_file` 解析后的附件 id 列表"
|
||||
items:
|
||||
type: string
|
||||
document_file_urls:
|
||||
type: array
|
||||
description: "根据 `document_file -> tbl_attachments` 自动解析出的文件流链接列表"
|
||||
items:
|
||||
type: string
|
||||
document_file_url:
|
||||
type: string
|
||||
description: "兼容字段,返回第一个文件的文件流链接"
|
||||
document_file_attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttachmentRecord'
|
||||
document_file_attachment:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/AttachmentRecord'
|
||||
- type: 'null'
|
||||
document_owner:
|
||||
type: string
|
||||
description: 上传者 openid
|
||||
@@ -630,6 +703,14 @@ components:
|
||||
items:
|
||||
type: string
|
||||
description: 视频附件 id 列表;支持数组或 `|` 分隔字符串
|
||||
document_file:
|
||||
oneOf:
|
||||
- type: string
|
||||
description: 多个文件附件 id 使用 `|` 分隔
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
description: 文件附件 id 列表;支持数组或 `|` 分隔字符串
|
||||
document_relation_model:
|
||||
type: string
|
||||
document_keywords:
|
||||
@@ -1237,7 +1318,7 @@ paths:
|
||||
summary: 删除附件
|
||||
description: |
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除。
|
||||
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image`、`document_video` 或 `document_file` 引用,则拒绝删除。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1278,8 +1359,8 @@ paths:
|
||||
description: |
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
|
||||
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments`,
|
||||
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。
|
||||
返回结果会自动根据 `document_image`、`document_video`、`document_file` 中的多个 `attachments_id` 关联 `tbl_attachments`,
|
||||
额外补充 `document_image_urls`、`document_video_urls`、`document_file_urls` 以及对应附件对象数组。
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -1352,7 +1433,7 @@ paths:
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
`document_id` 可选;未传时服务端自动生成。
|
||||
`document_title`、`document_type` 为必填;其余字段均允许为空。
|
||||
`document_image`、`document_video` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
|
||||
`document_image`、`document_video`、`document_file` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
|
||||
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1390,7 +1471,7 @@ paths:
|
||||
仅允许 `ManagePlatform` 用户访问。
|
||||
按 `document_id` 定位现有文档并更新。
|
||||
`document_title`、`document_type` 为必填;其余字段均允许为空。
|
||||
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。
|
||||
若传入 `document_image`、`document_video`、`document_file`,则支持多个 `attachments_id`,并会逐一校验是否存在。
|
||||
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -82,10 +82,10 @@
|
||||
| company_id | text | 公司id (存储 tbl_company.company_id) |
|
||||
| users_parent_id | text | 用户父级id (存储 tbl_users.users_id) |
|
||||
| users_promo_code | text | 用户推广码 |
|
||||
| users_id_pic_a | file | 用户证件照片(正) |
|
||||
| users_id_pic_b | file | 用户证件照片(反) |
|
||||
| users_title_picture | file | 用户资质照片 |
|
||||
| users_picture | file | 用户头像 |
|
||||
| users_id_pic_a | text | 用户证件照片(正),保存 tbl_attachments.attachments_id |
|
||||
| users_id_pic_b | text | 用户证件照片(反),保存 tbl_attachments.attachments_id |
|
||||
| users_title_picture | text | 用户资质照片,保存 tbl_attachments.attachments_id |
|
||||
| users_picture | text | 用户头像,保存 tbl_attachments.attachments_id |
|
||||
| usergroups_id | text | 用户组id (存储 tbl_user_groups.usergroups_id) |
|
||||
|
||||
**索引规划 (Indexes):**
|
||||
@@ -93,5 +93,3 @@
|
||||
* `CREATE UNIQUE INDEX` 针对 `users_phone` (确保手机号唯一,加速登录查询)
|
||||
* `CREATE UNIQUE INDEX` 针对 `users_wx_openid` (确保微信开放ID唯一)
|
||||
* `CREATE INDEX` 针对 `company_id`, `usergroups_id`, `users_parent_id` (加速这三个高频业务外键的匹配查询)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"init:newpb": "node pocketbase.newpb.js",
|
||||
"init:documents": "node pocketbase.documents.js",
|
||||
"init:dictionary": "node pocketbase.dictionary.js"
|
||||
"init:dictionary": "node pocketbase.dictionary.js",
|
||||
"migrate:file-fields": "node pocketbase.file-fields-to-attachments.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -24,6 +24,8 @@ const collections = [
|
||||
{
|
||||
name: 'tbl_attachments',
|
||||
type: 'base',
|
||||
listRule: '',
|
||||
viewRule: '',
|
||||
fields: [
|
||||
{ name: 'attachments_id', type: 'text', required: true },
|
||||
{ name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 4294967296, mimeTypes: [] },
|
||||
@@ -56,6 +58,7 @@ const collections = [
|
||||
{ name: 'document_content', type: 'text' },
|
||||
{ name: 'document_image', type: 'text' },
|
||||
{ name: 'document_video', type: 'text' },
|
||||
{ name: 'document_file', type: 'text' },
|
||||
{ name: 'document_owner', type: 'text' },
|
||||
{ name: 'document_relation_model', type: 'text' },
|
||||
{ name: 'document_keywords', type: 'text' },
|
||||
@@ -135,6 +138,11 @@ function buildCollectionPayload(collectionData, existingCollection) {
|
||||
return {
|
||||
name: collectionData.name,
|
||||
type: collectionData.type,
|
||||
listRule: Object.prototype.hasOwnProperty.call(collectionData, 'listRule') ? collectionData.listRule : null,
|
||||
viewRule: Object.prototype.hasOwnProperty.call(collectionData, 'viewRule') ? collectionData.viewRule : null,
|
||||
createRule: Object.prototype.hasOwnProperty.call(collectionData, 'createRule') ? collectionData.createRule : null,
|
||||
updateRule: Object.prototype.hasOwnProperty.call(collectionData, 'updateRule') ? collectionData.updateRule : null,
|
||||
deleteRule: Object.prototype.hasOwnProperty.call(collectionData, 'deleteRule') ? collectionData.deleteRule : null,
|
||||
fields: collectionData.fields.map((field) => normalizeFieldPayload(field, null)),
|
||||
indexes: collectionData.indexes,
|
||||
};
|
||||
@@ -158,6 +166,11 @@ function buildCollectionPayload(collectionData, existingCollection) {
|
||||
return {
|
||||
name: collectionData.name,
|
||||
type: collectionData.type,
|
||||
listRule: Object.prototype.hasOwnProperty.call(collectionData, 'listRule') ? collectionData.listRule : existingCollection.listRule,
|
||||
viewRule: Object.prototype.hasOwnProperty.call(collectionData, 'viewRule') ? collectionData.viewRule : existingCollection.viewRule,
|
||||
createRule: Object.prototype.hasOwnProperty.call(collectionData, 'createRule') ? collectionData.createRule : existingCollection.createRule,
|
||||
updateRule: Object.prototype.hasOwnProperty.call(collectionData, 'updateRule') ? collectionData.updateRule : existingCollection.updateRule,
|
||||
deleteRule: Object.prototype.hasOwnProperty.call(collectionData, 'deleteRule') ? collectionData.deleteRule : existingCollection.deleteRule,
|
||||
fields: fields,
|
||||
indexes: collectionData.indexes,
|
||||
};
|
||||
|
||||
142
script/pocketbase.file-fields-to-attachments.js
Normal file
142
script/pocketbase.file-fields-to-attachments.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createRequire } from 'module';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let runtimeConfig = {};
|
||||
try {
|
||||
runtimeConfig = require('../pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js');
|
||||
} catch (_error) {
|
||||
runtimeConfig = {};
|
||||
}
|
||||
|
||||
const PB_URL = (process.env.PB_URL || 'https://bai-api.blv-oa.com/pb').replace(/\/+$/, '');
|
||||
const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || runtimeConfig.POCKETBASE_AUTH_TOKEN || '';
|
||||
|
||||
if (!AUTH_TOKEN) {
|
||||
console.error('❌ 缺少 POCKETBASE_AUTH_TOKEN,无法执行文件字段迁移。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pb = new PocketBase(PB_URL);
|
||||
|
||||
function buildReplacementTextField(field) {
|
||||
return {
|
||||
name: field.name,
|
||||
type: 'text',
|
||||
required: !!field.required,
|
||||
hidden: !!field.hidden,
|
||||
presentable: typeof field.presentable === 'boolean' ? field.presentable : true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCollectionPayload(remote, fields) {
|
||||
const payload = {
|
||||
name: remote.name,
|
||||
type: remote.type,
|
||||
fields: fields,
|
||||
indexes: remote.indexes || [],
|
||||
listRule: remote.listRule,
|
||||
viewRule: remote.viewRule,
|
||||
createRule: remote.createRule,
|
||||
updateRule: remote.updateRule,
|
||||
deleteRule: remote.deleteRule,
|
||||
};
|
||||
|
||||
if (remote.name === 'tbl_attachments') {
|
||||
payload.listRule = '';
|
||||
payload.viewRule = '';
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function migrateCollections() {
|
||||
console.log(`🔄 正在连接 PocketBase: ${PB_URL}`);
|
||||
pb.authStore.save(AUTH_TOKEN, null);
|
||||
console.log('✅ 已使用 POCKETBASE_AUTH_TOKEN 载入认证状态。');
|
||||
|
||||
const collections = await pb.collections.getFullList({ sort: 'name' });
|
||||
const changed = [];
|
||||
|
||||
for (const remote of collections) {
|
||||
const fileFields = (remote.fields || []).filter((field) => field.type === 'file');
|
||||
const shouldOpenAttachments = remote.name === 'tbl_attachments' && (remote.listRule !== '' || remote.viewRule !== '');
|
||||
|
||||
if (!fileFields.length && !shouldOpenAttachments) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`🔄 正在处理集合: ${remote.name}`);
|
||||
if (remote.name === 'tbl_attachments') {
|
||||
await pb.collections.update(remote.id, buildCollectionPayload(remote, remote.fields || []));
|
||||
} else if (fileFields.length) {
|
||||
const remainingFields = (remote.fields || []).filter((field) => field.type !== 'file');
|
||||
await pb.collections.update(remote.id, buildCollectionPayload(remote, remainingFields));
|
||||
|
||||
const refreshed = await pb.collections.getOne(remote.name);
|
||||
const replacementFields = fileFields.map((field) => buildReplacementTextField(field));
|
||||
await pb.collections.update(
|
||||
refreshed.id,
|
||||
buildCollectionPayload(refreshed, (refreshed.fields || []).concat(replacementFields)),
|
||||
);
|
||||
}
|
||||
|
||||
changed.push({
|
||||
name: remote.name,
|
||||
convertedFileFields: remote.name === 'tbl_attachments'
|
||||
? []
|
||||
: fileFields.map((field) => field.name),
|
||||
attachmentsRulesOpened: remote.name === 'tbl_attachments',
|
||||
});
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function verifyResult() {
|
||||
const collections = await pb.collections.getFullList({ sort: 'name' });
|
||||
const residualFileFields = [];
|
||||
let attachmentsRules = null;
|
||||
|
||||
for (const remote of collections) {
|
||||
for (const field of (remote.fields || [])) {
|
||||
if (field.type === 'file' && remote.name !== 'tbl_attachments') {
|
||||
residualFileFields.push(`${remote.name}.${field.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (remote.name === 'tbl_attachments') {
|
||||
attachmentsRules = {
|
||||
listRule: remote.listRule,
|
||||
viewRule: remote.viewRule,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (residualFileFields.length) {
|
||||
throw new Error(`仍存在未迁移的 file 字段: ${residualFileFields.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!attachmentsRules || attachmentsRules.listRule !== '' || attachmentsRules.viewRule !== '') {
|
||||
throw new Error('tbl_attachments 的公开 list/view 规则未生效');
|
||||
}
|
||||
|
||||
console.log('✅ 校验通过:除 tbl_attachments 外已无 file 字段。');
|
||||
console.log('✅ 校验通过:tbl_attachments 已开放公共 list/view。');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const changed = await migrateCollections();
|
||||
console.log('📝 本次更新集合:');
|
||||
console.log(JSON.stringify(changed, null, 2));
|
||||
await verifyResult();
|
||||
console.log('🎉 文件字段迁移完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 文件字段迁移失败:', error.response?.data || error.message || error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -81,10 +81,10 @@ const collections = [
|
||||
{ name: 'company_id', type: 'text' },
|
||||
{ name: 'users_parent_id', type: 'text' },
|
||||
{ name: 'users_promo_code', type: 'text' },
|
||||
{ name: 'users_id_pic_a', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_id_pic_b', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_title_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_id_pic_a', type: 'text' },
|
||||
{ name: 'users_id_pic_b', type: 'text' },
|
||||
{ name: 'users_title_picture', type: 'text' },
|
||||
{ name: 'users_picture', type: 'text' },
|
||||
{ name: 'usergroups_id', type: 'text' }
|
||||
],
|
||||
indexes: [
|
||||
@@ -174,4 +174,4 @@ async function verifyCollections(targetCollections) {
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
init();
|
||||
|
||||
@@ -56,9 +56,9 @@ const collections = [
|
||||
{ name: 'company_id', type: 'text' },
|
||||
{ name: 'users_parent_id', type: 'text' },
|
||||
{ name: 'users_promo_code', type: 'text' },
|
||||
{ name: 'users_id_pic_a', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_id_pic_b', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_title_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } },
|
||||
{ name: 'users_id_pic_a', type: 'text' },
|
||||
{ name: 'users_id_pic_b', type: 'text' },
|
||||
{ name: 'users_title_picture', type: 'text' },
|
||||
{ name: 'users_picture', type: 'text' },
|
||||
{ name: 'usergroups_id', type: 'text' }
|
||||
],
|
||||
@@ -256,4 +256,4 @@ async function verifyCollections(targetCollections) {
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user