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:
2026-03-28 15:13:04 +08:00
parent eaf282ea24
commit 51a90260e4
50 changed files with 4250 additions and 113 deletions

View 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

View 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

View 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

View 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
View File

@@ -1 +1,2 @@
/back-end/node_modules /back-end/node_modules
/.tmp-upload-probe

View File

@@ -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 - 2026-03-23
## 归档范围 ## 归档范围

View File

@@ -5,8 +5,8 @@
本文档描述当前项目中**已经真实实现**并可直接调用的后端接口。 本文档描述当前项目中**已经真实实现**并可直接调用的后端接口。
当前接口统一特征如下: 当前接口统一特征如下:
- 基础路径(生产):`https://bai-api.blv-oa.com/api` - 基础路径(生产):`https://bai-api.blv-oa.com/pb/api`
- 基础路径(本地):`http://localhost:3000/api` - 基础路径(本地):`http://localhost:8090/pb/api`
- 响应格式JSON - 响应格式JSON
- 业务响应结构统一为:`code``msg``data` - 业务响应结构统一为:`code``msg``data`
- 当前公开接口统一使用 **POST** 方法 - 当前公开接口统一使用 **POST** 方法
@@ -134,7 +134,8 @@
"users_name": "张三", "users_name": "张三",
"users_phone": "13800138000", "users_phone": "13800138000",
"users_phone_masked": "138****8000", "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", "openid": "oAbCdEfGh123456789",
"company_id": "C10001", "company_id": "C10001",
"company": null, "company": null,
@@ -162,7 +163,10 @@
{ {
"users_name": "张三", "users_name": "张三",
"users_phone_code": "2b7d9f2e3c4a5b6d7e8f", "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_name` | string | 是 | 用户姓名 |
| `users_phone_code` | string | 是 | 微信手机号获取凭证 code后端将据此换取真实手机号 | | `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` - 不再从 body 读取 `users_wx_code`
- 使用 `users_phone_code` 调微信官方接口换取真实手机号 - 使用 `users_phone_code` 调微信官方接口换取真实手机号
- 将真实手机号写入数据库字段 `users_phone` - 将真实手机号写入数据库字段 `users_phone`
- `users_picture``users_id_pic_a``users_id_pic_b``users_title_picture` 均按 `attachments_id` 存储,服务端查询用户信息时会自动补充对应文件流链接
- 若用户首次从“三项资料均为空”变为“三项资料均完整”,则将 `users_type``游客` 升级为 `注册用户` - 若用户首次从“三项资料均为空”变为“三项资料均完整”,则将 `users_type``游客` 升级为 `注册用户`
- 返回更新后的完整用户信息 - 返回更新后的完整用户信息
@@ -198,7 +206,14 @@
"users_name": "张三", "users_name": "张三",
"users_phone": "13800138000", "users_phone": "13800138000",
"users_phone_masked": "138****8000", "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", "openid": "oAbCdEfGh123456789",
"company_id": "", "company_id": "",
"company": null, "company": null,

View File

@@ -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_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_keywords``document_product_categories``document_application_scenarios``document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`
- `document_status` 仅允许 `有效``过期` 两种值,并由系统根据生效日期与到期日期自动计算;当两者都为空时默认 `有效` - `document_status` 仅允许 `有效``过期` 两种值,并由系统根据生效日期与到期日期自动计算;当两者都为空时默认 `有效`
- `document_owner` 的业务含义为“上传者openid”。 - `document_owner` 的业务含义为“上传者openid”。
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。 - `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
- `tbl_attachments` 的文件查看/下载权限应保持公开;真正的业务访问控制交由引用 `attachments_id` 的业务表和业务接口决定。
- 文档字段中,面向用户填写的字段里只有 `document_title``document_type` 设为必填,其余字段均允许为空。 - 文档字段中,面向用户填写的字段里只有 `document_title``document_type` 设为必填,其余字段均允许为空。
--- ---
@@ -55,6 +56,7 @@
| document_content | text | 正文内容,保存 Markdown 原文 | | document_content | text | 正文内容,保存 Markdown 原文 |
| document_image | text | 关联多个 `attachments_id`,使用 `|` 分隔 | | document_image | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
| document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 | | document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
| document_file | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
| document_owner | text | 上传者openid | | document_owner | text | 上传者openid |
| document_relation_model | text | 关联机型/模型标识 | | document_relation_model | text | 关联机型/模型标识 |
| document_keywords | text | 关键词,多选后用 `|` 分隔保存 | | document_keywords | text | 关键词,多选后用 `|` 分隔保存 |

View File

@@ -32,10 +32,10 @@
| `company_id` | `text` | 否 | 公司 ID | | `company_id` | `text` | 否 | 公司 ID |
| `users_parent_id` | `text` | 否 | 上级用户 ID | | `users_parent_id` | `text` | 否 | 上级用户 ID |
| `users_promo_code` | `text` | 否 | 推广码 | | `users_promo_code` | `text` | 否 | 推广码 |
| `users_id_pic_a` | `file` | 否 | 证件照正面,`maxSelect: 1`,允许 `jpeg/png/webp` | | `users_id_pic_a` | `text` | 否 | 保存 `tbl_attachments.attachments_id`,用于关联证件照正面 |
| `users_id_pic_b` | `file` | 否 | 证件照反面,`maxSelect: 1`,允许 `jpeg/png/webp` | | `users_id_pic_b` | `text` | 否 | 保存 `tbl_attachments.attachments_id`,用于关联证件照反面 |
| `users_title_picture` | `file` | 否 | 资质照片,`maxSelect: 1`,允许 `jpeg/png/webp` | | `users_title_picture` | `text` | 否 | 保存 `tbl_attachments.attachments_id`,用于关联资质照片 |
| `users_picture` | `text` | 否 | 用户头像 | | `users_picture` | `text` | 否 | 用户头像,保存 `tbl_attachments.attachments_id` |
| `usergroups_id` | `text` | 否 | 用户组 ID | | `usergroups_id` | `text` | 否 | 用户组 ID |
### 索引 ### 索引

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View File

@@ -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
- 暂无新的未决问题;当前能力范围已经按现有实现归档。

View File

@@ -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 一致。

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 记录本次变更并完成归档。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View 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

View 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

View 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

View File

@@ -156,6 +156,10 @@ PocketBase JSVM 不是 Node.js 运行时:
- `pocket-base/spec/openapi.yaml` - `pocket-base/spec/openapi.yaml`
- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md` - `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` 索引由唯一改为普通索引 - `users_phone` 索引由唯一改为普通索引
- `tbl_auth_users` 以全平台统一 `openid` 为业务身份锚点 - `tbl_auth_users` 以全平台统一 `openid` 为业务身份锚点
- auth 集合兼容占位 `email`、随机密码与 `passwordConfirm` - auth 集合兼容占位 `email`、随机密码与 `passwordConfirm`
- 业务文件统一收敛到 `tbl_attachments`
- `tbl_document` 新增 `document_file`
- 文档管理页支持图片 / 视频 / 文件三类附件
- SDK 直连权限页支持按角色配置 collection CRUD 权限
## 与原项目关系 ## 与原项目关系

View File

@@ -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/update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/delete.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/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/login.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`)

View File

@@ -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/login.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.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/dictionary-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)

View File

@@ -25,4 +25,4 @@ routerAdd('POST', '/api/attachment/upload', function (e) {
data: (err && err.data) || {}, data: (err && err.data) || {},
}) })
} }
}) }, $apis.bodyLimit(536870912))

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -28,7 +28,14 @@ function validateProfileBody(e) {
if (!payload.users_name) throw createAppError(400, 'users_name 为必填项') if (!payload.users_name) throw createAppError(400, 'users_name 为必填项')
if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项') if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项')
if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项') 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) { function validatePlatformRegisterBody(e) {
@@ -44,7 +51,24 @@ function validatePlatformRegisterBody(e) {
throw createAppError(400, 'password 与 passwordConfirm 不一致') 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) { function validatePlatformLoginBody(e) {
@@ -193,7 +217,21 @@ function validateAttachmentDeleteBody(e) {
} }
function validateAttachmentUploadBody(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 { return {
attachments_filename: payload.attachments_filename || '', attachments_filename: payload.attachments_filename || '',
@@ -282,6 +320,7 @@ function validateDocumentMutationBody(e, isUpdate) {
document_content: payload.document_content || '', document_content: payload.document_content || '',
document_image: normalizeAttachmentIdList(payload.document_image, 'document_image'), document_image: normalizeAttachmentIdList(payload.document_image, 'document_image'),
document_video: normalizeAttachmentIdList(payload.document_video, 'document_video'), document_video: normalizeAttachmentIdList(payload.document_video, 'document_video'),
document_file: normalizeAttachmentIdList(payload.document_file, 'document_file'),
document_relation_model: payload.document_relation_model || '', document_relation_model: payload.document_relation_model || '',
document_keywords: payload.document_keywords || '', document_keywords: payload.document_keywords || '',
document_share_count: typeof payload.document_share_count === 'undefined' ? '' : payload.document_share_count, 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) { function requireAuthOpenid(e) {
if (!e.auth) { if (!e.auth) {
throw createAppError(401, '认证令牌无效或已过期') throw createAppError(401, '认证令牌无效或已过期')
@@ -389,6 +514,11 @@ module.exports = {
validateDocumentMutationBody, validateDocumentMutationBody,
validateDocumentDeleteBody, validateDocumentDeleteBody,
validateDocumentHistoryListBody, validateDocumentHistoryListBody,
validateSdkPermissionContextBody,
validateSdkPermissionRoleBody,
validateSdkPermissionRoleDeleteBody,
validateSdkPermissionUserRoleBody,
validateSdkPermissionCollectionSaveBody,
requireAuthOpenid, requireAuthOpenid,
requireAuthUser, requireAuthUser,
duplicateGuard, duplicateGuard,

View File

@@ -26,7 +26,7 @@ function buildFileUrl(collectionName, recordId, filename, download) {
function normalizeDateValue(value) { function normalizeDateValue(value) {
const text = String(value || '').replace(/^\s+|\s+$/g, '') const text = String(value || '').replace(/^\s+|\s+$/g, '')
if (!text) return '' if (!text) return null
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) { if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return text + ' 00:00:00.000Z' return text + ' 00:00:00.000Z'
@@ -198,8 +198,10 @@ function exportDocumentRecord(record) {
const documentStatus = ensureDocumentStatus(record) const documentStatus = ensureDocumentStatus(record)
const imageAttachmentList = resolveAttachmentList(record.getString('document_image')) const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
const videoAttachmentList = resolveAttachmentList(record.getString('document_video')) const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
const fileAttachmentList = resolveAttachmentList(record.getString('document_file'))
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
const firstVideoAttachment = videoAttachmentList.attachments.length ? videoAttachmentList.attachments[0] : null const firstVideoAttachment = videoAttachmentList.attachments.length ? videoAttachmentList.attachments[0] : null
const firstFileAttachment = fileAttachmentList.attachments.length ? fileAttachmentList.attachments[0] : null
return { return {
pb_id: record.id, pb_id: record.id,
@@ -223,6 +225,12 @@ function exportDocumentRecord(record) {
document_video_attachments: videoAttachmentList.attachments, document_video_attachments: videoAttachmentList.attachments,
document_video_url: firstVideoAttachment ? firstVideoAttachment.attachments_url : '', document_video_url: firstVideoAttachment ? firstVideoAttachment.attachments_url : '',
document_video_attachment: firstVideoAttachment, 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_owner: record.getString('document_owner'),
document_relation_model: record.getString('document_relation_model'), document_relation_model: record.getString('document_relation_model'),
document_keywords: record.getString('document_keywords'), 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) { function createHistoryRecord(txApp, payload) {
const collection = txApp.findCollectionByNameOrId('tbl_document_operation_history') const collection = txApp.findCollectionByNameOrId('tbl_document_operation_history')
const record = new Record(collection) const record = new Record(collection)
@@ -368,7 +407,9 @@ function deleteAttachment(attachmentId) {
const imageIds = parseAttachmentIdList(current.getString('document_image')) const imageIds = parseAttachmentIdList(current.getString('document_image'))
const videoIds = parseAttachmentIdList(current.getString('document_video')) 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, '附件已被文档引用,无法删除') throw createAppError(400, '附件已被文档引用,无法删除')
} }
} }
@@ -433,6 +474,7 @@ function getDocumentDetail(documentId) {
function createDocument(userOpenid, payload) { function createDocument(userOpenid, payload) {
ensureAttachmentIdsExist(payload.document_image, 'document_image') ensureAttachmentIdsExist(payload.document_image, 'document_image')
ensureAttachmentIdsExist(payload.document_video, 'document_video') ensureAttachmentIdsExist(payload.document_video, 'document_video')
ensureAttachmentIdsExist(payload.document_file, 'document_file')
const targetDocumentId = payload.document_id || buildBusinessId('DOC') const targetDocumentId = payload.document_id || buildBusinessId('DOC')
const duplicated = findDocumentRecordByDocumentId(targetDocumentId) const duplicated = findDocumentRecordByDocumentId(targetDocumentId)
@@ -440,7 +482,7 @@ function createDocument(userOpenid, payload) {
throw createAppError(400, 'document_id 已存在') throw createAppError(400, 'document_id 已存在')
} }
return $app.runInTransaction(function (txApp) { return runInTransactionSafely('创建文档', function (txApp) {
const collection = txApp.findCollectionByNameOrId('tbl_document') const collection = txApp.findCollectionByNameOrId('tbl_document')
const record = new Record(collection) const record = new Record(collection)
const effectDateValue = normalizeDateValue(payload.document_effect_date) 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_content', payload.document_content || '')
record.set('document_image', serializeAttachmentIdList(payload.document_image)) record.set('document_image', serializeAttachmentIdList(payload.document_image))
record.set('document_video', serializeAttachmentIdList(payload.document_video)) record.set('document_video', serializeAttachmentIdList(payload.document_video))
record.set('document_file', serializeAttachmentIdList(payload.document_file))
record.set('document_owner', userOpenid || '') record.set('document_owner', userOpenid || '')
record.set('document_relation_model', payload.document_relation_model || '') record.set('document_relation_model', payload.document_relation_model || '')
record.set('document_keywords', payload.document_keywords || '') record.set('document_keywords', payload.document_keywords || '')
record.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count')) record.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count'))
record.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count')) record.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count'))
record.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count')) record.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count'))
record.set('document_status', documentStatus) record.set('document_status', documentStatus)
record.set('document_embedding_status', payload.document_embedding_status || '') record.set('document_embedding_status', payload.document_embedding_status || '')
record.set('document_embedding_error', payload.document_embedding_error || '') 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_hotel_type', payload.document_hotel_type || '')
record.set('document_remark', payload.document_remark || '') 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, { createHistoryRecord(txApp, {
documentId: record.getString('document_id'), documentId: record.getString('document_id'),
@@ -495,8 +545,9 @@ function updateDocument(userOpenid, payload) {
ensureAttachmentIdsExist(payload.document_image, 'document_image') ensureAttachmentIdsExist(payload.document_image, 'document_image')
ensureAttachmentIdsExist(payload.document_video, 'document_video') 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 target = txApp.findRecordById('tbl_document', record.id)
const effectDateValue = normalizeDateValue(payload.document_effect_date) const effectDateValue = normalizeDateValue(payload.document_effect_date)
const expiryDateValue = normalizeDateValue(payload.document_expiry_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_content', payload.document_content || '')
target.set('document_image', serializeAttachmentIdList(payload.document_image)) target.set('document_image', serializeAttachmentIdList(payload.document_image))
target.set('document_video', serializeAttachmentIdList(payload.document_video)) 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_relation_model', payload.document_relation_model || '')
target.set('document_keywords', payload.document_keywords || '') target.set('document_keywords', payload.document_keywords || '')
target.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count')) target.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count'))
target.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count')) target.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count'))
target.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count')) target.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count'))
target.set('document_status', documentStatus) target.set('document_status', documentStatus)
target.set('document_embedding_status', payload.document_embedding_status || '') target.set('document_embedding_status', payload.document_embedding_status || '')
target.set('document_embedding_error', payload.document_embedding_error || '') 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_hotel_type', payload.document_hotel_type || '')
target.set('document_remark', payload.document_remark || '') 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, { createHistoryRecord(txApp, {
documentId: target.getString('document_id'), documentId: target.getString('document_id'),
@@ -546,7 +605,7 @@ function deleteDocument(userOpenid, documentId) {
throw createAppError(404, '未找到待删除的文档') throw createAppError(404, '未找到待删除的文档')
} }
return $app.runInTransaction(function (txApp) { return runInTransactionSafely('删除文档', function (txApp) {
createHistoryRecord(txApp, { createHistoryRecord(txApp, {
documentId: documentId, documentId: documentId,
operationType: 'delete', operationType: 'delete',

View File

@@ -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,
}

View File

@@ -1,6 +1,7 @@
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) 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 { 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 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 env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
const GUEST_USER_TYPE = '游客' const GUEST_USER_TYPE = '游客'
@@ -124,10 +125,68 @@ function exportCompany(companyRecord) {
return companyRecord.publicExport() 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) { function enrichUser(userRecord) {
const companyId = userRecord.getString('company_id') const companyId = userRecord.getString('company_id')
const companyRecord = getCompanyByCompanyId(companyId) const companyRecord = getCompanyByCompanyId(companyId)
const openid = userRecord.getString('openid') 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 { return {
pb_id: userRecord.id, pb_id: userRecord.id,
@@ -143,7 +202,18 @@ function enrichUser(userRecord) {
users_phone: userRecord.getString('users_phone'), users_phone: userRecord.getString('users_phone'),
users_phone_masked: maskPhone(userRecord.getString('users_phone')), users_phone_masked: maskPhone(userRecord.getString('users_phone')),
users_level: userRecord.getString('users_level'), 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, openid: openid,
company_id: companyId || '', company_id: companyId || '',
users_parent_id: userRecord.getString('users_parent_id'), 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_idtype', MANAGE_PLATFORM_ID_TYPE)
record.set('users_name', payload.users_name) record.set('users_name', payload.users_name)
record.set('users_phone', payload.users_phone) 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_id_number', payload.users_id_number || '')
record.set('users_level', payload.users_level || '') record.set('users_level', payload.users_level || '')
record.set('users_type', payload.users_type || REGISTERED_USER_TYPE) 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.set('email', payload.email || (platformOpenid + '@manage.local'))
record.setPassword(payload.password) record.setPassword(payload.password)
record.set('passwordConfirm', payload.passwordConfirm) record.set('passwordConfirm', payload.passwordConfirm)
applyUserAttachmentFields(record, payload)
saveAuthUserRecord(record) saveAuthUserRecord(record)
@@ -432,7 +502,7 @@ function updateWechatUserProfile(usersWxOpenid, payload) {
currentUser.set('users_name', payload.users_name) currentUser.set('users_name', payload.users_name)
currentUser.set('users_phone', usersPhone) currentUser.set('users_phone', usersPhone)
currentUser.set('users_picture', payload.users_picture) applyUserAttachmentFields(currentUser, payload)
if (shouldPromote) { if (shouldPromote) {
currentUser.set('users_type', REGISTERED_USER_TYPE) currentUser.set('users_type', REGISTERED_USER_TYPE)
} }

View File

@@ -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) { 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 = { module.exports = {

View File

@@ -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; } .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; } .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 { 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-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.thumb-meta { font-size: 12px; color: #64748b; word-break: break-all; } .thumb-meta { font-size: 12px; color: #64748b; word-break: break-all; }
.enum-preview-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } .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; } .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; } .icon-btn { min-width: 36px; padding: 6px 10px; line-height: 1; font-size: 18px; }
.drop-tip { color: #64748b; font-size: 12px; margin-top: 6px; } .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) { @media (max-width: 960px) {
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; } .toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
table, thead, tbody, th, td, tr { display: block; } table, thead, tbody, th, td, tr { display: block; }
@@ -169,6 +180,18 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
</div> </div>
</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> <script>
const API_BASE = '/pb/api' const API_BASE = '/pb/api'
const tokenKey = 'pb_manage_token' const tokenKey = 'pb_manage_token'
@@ -193,12 +216,43 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
const enabledInput = document.getElementById('enabledInput') const enabledInput = document.getElementById('enabledInput')
const remarkInput = document.getElementById('remarkInput') const remarkInput = document.getElementById('remarkInput')
const itemsBody = document.getElementById('itemsBody') 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) { function setStatus(message, type) {
statusEl.textContent = message || '' statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '') 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() { function getToken() {
return localStorage.getItem(tokenKey) || '' return localStorage.getItem(tokenKey) || ''
} }
@@ -235,6 +289,36 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
return data.data 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) { async function uploadAttachment(file, label) {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
@@ -259,8 +343,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
body: form, 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.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) || '上传图片失败') throw new Error((data && data.msg) || '上传图片失败')
} }
@@ -333,9 +424,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
function renderEnumPreviewItem(item) { function renderEnumPreviewItem(item) {
const desc = escapeHtml(item && item.description ? item.description : '(无描述)') const desc = escapeHtml(item && item.description ? item.description : '(无描述)')
const imageHtml = item && item.imageUrl 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>' : '<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) { function renderItemsPreview(items, previewKey, isExpanded) {
@@ -443,7 +534,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
function renderItemsEditor() { function renderItemsEditor() {
itemsBody.innerHTML = state.items.map(function (item, index) { itemsBody.innerHTML = state.items.map(function (item, index) {
const imageCell = item.imageUrl 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>' : '<div class="muted">未上传图片</div>'
return '<tr>' return '<tr>'
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>' + '<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('正在上传字典项图片...', '') setStatus('正在上传字典项图片...', '')
showLoading('正在上传字典项图片,请稍候...')
try { try {
const attachment = await uploadAttachment(file, 'dict-item-' + (index + 1)) const attachment = await uploadAttachment(file, 'dict-item-' + (index + 1))
state.items[index].image = attachment.attachments_id state.items[index].image = attachment.attachments_id
@@ -481,6 +573,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus('字典项图片上传成功。', 'success') setStatus('字典项图片上传成功。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '字典项图片上传失败', 'error') setStatus(err.message || '字典项图片上传失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -515,6 +609,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
async function loadList() { async function loadList() {
setStatus('正在查询字典列表...', '') setStatus('正在查询字典列表...', '')
showLoading('正在查询字典列表...')
try { try {
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() }) const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
state.list = data.items || [] state.list = data.items || []
@@ -523,6 +618,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success') setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '查询失败', 'error') setStatus(err.message || '查询失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -534,6 +631,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
} }
setStatus('正在查询字典详情...', '') setStatus('正在查询字典详情...', '')
showLoading('正在查询字典详情...')
try { try {
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName }) const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
state.list = [data] state.list = [data]
@@ -542,6 +640,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus('查询详情成功。', 'success') setStatus('查询详情成功。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '查询失败', 'error') setStatus(err.message || '查询失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -559,6 +659,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
} }
setStatus('正在保存字典...', '') setStatus('正在保存字典...', '')
showLoading('正在保存字典,请稍候...')
try { try {
await request(state.mode === 'create' ? API_BASE + '/dictionary/create' : API_BASE + '/dictionary/update', payload) await request(state.mode === 'create' ? API_BASE + '/dictionary/create' : API_BASE + '/dictionary/update', payload)
closeModal() closeModal()
@@ -566,6 +667,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus(state.mode === 'create' ? '新增成功。' : '修改成功。', 'success') setStatus(state.mode === 'create' ? '新增成功。' : '修改成功。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '保存失败', 'error') setStatus(err.message || '保存失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -591,12 +694,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
} }
setStatus('正在保存行数据...', '') setStatus('正在保存行数据...', '')
showLoading('正在保存行数据,请稍候...')
try { try {
await request(API_BASE + '/dictionary/update', payload) await request(API_BASE + '/dictionary/update', payload)
await loadList() await loadList()
setStatus('行内保存成功。', 'success') setStatus('行内保存成功。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '保存失败', 'error') setStatus(err.message || '保存失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -607,12 +713,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
} }
setStatus('正在删除字典...', '') setStatus('正在删除字典...', '')
showLoading('正在删除字典,请稍候...')
try { try {
await request(API_BASE + '/dictionary/delete', { dict_name: targetName }) await request(API_BASE + '/dictionary/delete', { dict_name: targetName })
await loadList() await loadList()
setStatus('删除成功。', 'success') setStatus('删除成功。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '删除失败', 'error') setStatus(err.message || '删除失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -632,6 +741,13 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
state.expandedPreviewKey = state.expandedPreviewKey === previewKey ? '' : previewKey state.expandedPreviewKey = state.expandedPreviewKey === previewKey ? '' : previewKey
renderTable(state.list) renderTable(state.list)
} }
window.__previewImage = function (url) {
if (!url) {
return
}
imageViewerImg.src = url
imageViewer.classList.add('show')
}
window.__uploadItemImage = uploadItemImage window.__uploadItemImage = uploadItemImage
window.__allowItemDrop = function (event) { window.__allowItemDrop = function (event) {
event.preventDefault() event.preventDefault()
@@ -675,6 +791,22 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
document.getElementById('createBtn').addEventListener('click', function () { openModal('create') }) document.getElementById('createBtn').addEventListener('click', function () { openModal('create') })
document.getElementById('closeModalBtn').addEventListener('click', closeModal) document.getElementById('closeModalBtn').addEventListener('click', closeModal)
document.getElementById('cancelBtn').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 () { document.getElementById('addItemBtn').addEventListener('click', function () {
syncItemsStateFromEditor() syncItemsStateFromEditor()
const used = new Set(state.items.map(function (item) { return String(item.enum || '') })) const used = new Set(state.items.map(function (item) { return String(item.enum || '') }))

View File

@@ -30,6 +30,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.status { margin-top: 14px; min-height: 24px; font-size: 14px; } .status { margin-top: 14px; min-height: 24px; font-size: 14px; }
.status.success { color: #15803d; } .status.success { color: #15803d; }
.status.error { color: #b91c1c; } .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; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.full { grid-column: 1 / -1; } .full { grid-column: 1 / -1; }
label { display: block; margin-bottom: 8px; color: #334155; font-weight: 600; } 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; } .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; } .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; } .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; } .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-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; } .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-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.file-card-title { font-weight: 700; word-break: break-all; } .file-card-title { font-weight: 700; word-break: break-all; }
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; 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) { @media (max-width: 960px) {
.grid, .file-group { grid-template-columns: 1fr; } .grid, .file-group { grid-template-columns: 1fr; }
.option-list { grid-template-columns: repeat(2, minmax(0, 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> <div class="status" id="status"></div>
</section> </section>
<section class="panel"> <section class="panel editor-panel" id="editorPanel">
<div class="toolbar" style="justify-content:space-between;align-items:center;"> <div class="toolbar" style="justify-content:space-between;align-items:center;">
<div> <div>
<h2 id="formTitle">新增文档</h2> <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="videoCurrentList"></div>
<div class="file-list" id="videoPendingList"></div> <div class="file-list" id="videoPendingList"></div>
</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>
<div class="form-actions" style="margin-top:16px;"> <div class="form-actions" style="margin-top:16px;">
<button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button> <button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button>
<button class="btn btn-warning" id="cancelEditBtn" type="button">取消编辑</button> <button class="btn btn-warning" id="cancelEditBtn" type="button">取消编辑</button>
<button class="btn btn-light" id="resetBtn" type="button">重置表单</button> <button class="btn btn-light" id="resetBtn" type="button">重置表单</button>
</div> </div>
<div class="status" id="editorStatus"></div>
</section> </section>
<section class="panel"> <section class="panel">
@@ -242,10 +267,24 @@ routerAdd('GET', '/manage/document-manage', function (e) {
</section> </section>
</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> <script>
const tokenKey = 'pb_manage_token' const tokenKey = 'pb_manage_token'
const API_BASE = '/pb/api' const API_BASE = '/pb/api'
const statusEl = document.getElementById('status') const statusEl = document.getElementById('status')
const editorStatusEl = document.getElementById('editorStatus')
const editorPanelEl = document.getElementById('editorPanel')
const tableBody = document.getElementById('tableBody') const tableBody = document.getElementById('tableBody')
const formTitleEl = document.getElementById('formTitle') const formTitleEl = document.getElementById('formTitle')
const editorModeEl = document.getElementById('editorMode') const editorModeEl = document.getElementById('editorMode')
@@ -253,8 +292,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const imagePendingListEl = document.getElementById('imagePendingList') const imagePendingListEl = document.getElementById('imagePendingList')
const videoCurrentListEl = document.getElementById('videoCurrentList') const videoCurrentListEl = document.getElementById('videoCurrentList')
const videoPendingListEl = document.getElementById('videoPendingList') const videoPendingListEl = document.getElementById('videoPendingList')
const documentFileCurrentListEl = document.getElementById('documentFileCurrentList')
const documentFilePendingListEl = document.getElementById('documentFilePendingList')
const imageDropzoneEl = document.getElementById('imageDropzone') const imageDropzoneEl = document.getElementById('imageDropzone')
const videoDropzoneEl = document.getElementById('videoDropzone') const videoDropzoneEl = document.getElementById('videoDropzone')
const documentFileDropzoneEl = document.getElementById('documentFileDropzone')
const documentTypeSourceEl = document.getElementById('documentTypeSource') const documentTypeSourceEl = document.getElementById('documentTypeSource')
const documentTypeOptionsEl = document.getElementById('documentTypeOptions') const documentTypeOptionsEl = document.getElementById('documentTypeOptions')
const documentTypeTagsEl = document.getElementById('documentTypeTags') const documentTypeTagsEl = document.getElementById('documentTypeTags')
@@ -267,6 +309,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const applicationScenariosTagsEl = document.getElementById('applicationScenariosTags') const applicationScenariosTagsEl = document.getElementById('applicationScenariosTags')
const hotelTypeOptionsEl = document.getElementById('hotelTypeOptions') const hotelTypeOptionsEl = document.getElementById('hotelTypeOptions')
const hotelTypeTagsEl = document.getElementById('hotelTypeTags') 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 = { const fields = {
documentTitle: document.getElementById('documentTitle'), documentTitle: document.getElementById('documentTitle'),
documentStatus: document.getElementById('documentStatus'), documentStatus: document.getElementById('documentStatus'),
@@ -281,6 +328,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
documentRemark: document.getElementById('documentRemark'), documentRemark: document.getElementById('documentRemark'),
imageFile: document.getElementById('imageFile'), imageFile: document.getElementById('imageFile'),
videoFile: document.getElementById('videoFile'), videoFile: document.getElementById('videoFile'),
documentFile: document.getElementById('documentFile'),
} }
const dictionaryFieldConfig = { const dictionaryFieldConfig = {
documentKeywords: { documentKeywords: {
@@ -306,13 +354,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
} }
const state = { const state = {
list: [], list: [],
mode: 'create', mode: 'idle',
editingId: '', editingId: '',
editingSource: null, editingSource: null,
currentImageAttachments: [], currentImageAttachments: [],
currentVideoAttachments: [], currentVideoAttachments: [],
currentFileAttachments: [],
pendingImageFiles: [], pendingImageFiles: [],
pendingVideoFiles: [], pendingVideoFiles: [],
pendingDocumentFiles: [],
removedAttachmentIds: [], removedAttachmentIds: [],
dictionaries: [], dictionaries: [],
dictionariesByName: {}, dictionariesByName: {},
@@ -330,6 +380,43 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function setStatus(message, type) { function setStatus(message, type) {
statusEl.textContent = message || '' statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '') 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() { function getToken() {
@@ -549,29 +636,40 @@ routerAdd('GET', '/manage/document-manage', function (e) {
} }
async function loadDictionaries() { async function loadDictionaries() {
const data = await requestJson('/dictionary/list', {}) showLoading('正在加载字典选项...')
state.dictionaries = Array.isArray(data.items) ? data.items : [] try {
state.dictionariesByName = {} const data = await requestJson('/dictionary/list', {})
state.dictionariesById = {} state.dictionaries = Array.isArray(data.items) ? data.items : []
state.dictionariesByName = {}
state.dictionariesById = {}
for (let i = 0; i < state.dictionaries.length; i += 1) { for (let i = 0; i < state.dictionaries.length; i += 1) {
const item = state.dictionaries[i] const item = state.dictionaries[i]
state.dictionariesByName[item.dict_name] = item state.dictionariesByName[item.dict_name] = item
state.dictionariesById[item.system_dict_id] = item state.dictionariesById[item.system_dict_id] = item
}
renderDictionarySelectors()
} finally {
hideLoading()
} }
renderDictionarySelectors()
} }
function updateEditorMode() { function updateEditorMode() {
setEditorVisible(state.mode === 'create' || state.mode === 'edit')
if (state.mode === 'edit') { if (state.mode === 'edit') {
formTitleEl.textContent = '编辑文档' formTitleEl.textContent = '编辑文档'
editorModeEl.textContent = '当前模式:编辑 ' + state.editingId editorModeEl.textContent = '当前模式:编辑 ' + state.editingId
document.getElementById('submitBtn').textContent = '保存文档修改' document.getElementById('submitBtn').textContent = '保存文档修改'
} else { } else if (state.mode === 'create') {
formTitleEl.textContent = '新增文档' formTitleEl.textContent = '新增文档'
editorModeEl.textContent = '当前模式:新建' editorModeEl.textContent = '当前模式:新建'
document.getElementById('submitBtn').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') localStorage.removeItem('pb_manage_logged_in')
window.location.replace('/pb/manage/login') 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 return data.data
@@ -661,7 +764,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const data = parsed.json const data = parsed.json
if (!res.ok || !data || data.code >= 400) { if (!res.ok || !data || data.code >= 400) {
if (res.status === 413) { if (res.status === 413) {
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小。当前附件字段已放宽到约 4GB但线上反向代理也需要同步放开到相应体积。') throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小,或线上服务仍在运行旧版 hooks。')
} }
if (!parsed.isJson && parsed.text) { if (!parsed.isJson && parsed.text) {
@@ -693,6 +796,26 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.replace(/'/g, '&#39;') .replace(/'/g, '&#39;')
} }
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) { function renderAttachmentCards(container, items, category, pending) {
if (!items.length) { if (!items.length) {
container.innerHTML = '<div class="muted">' + (pending ? '暂无待上传附件。' : '暂无已绑定附件。') + '</div>' container.innerHTML = '<div class="muted">' + (pending ? '暂无待上传附件。' : '暂无已绑定附件。') + '</div>'
@@ -709,11 +832,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const linkHtml = pending || !item.attachments_url const linkHtml = pending || !item.attachments_url
? '' ? ''
: '<a href="' + escapeHtml(item.attachments_url) + '" target="_blank" rel="noreferrer">打开文件</a>' : '<a href="' + escapeHtml(item.attachments_url) + '" target="_blank" rel="noreferrer">打开文件</a>'
const previewHtml = category === 'image'
? renderImageThumb(getAttachmentPreviewUrl(item, pending), title)
: ''
const actionLabel = pending ? '移除待上传' : '从文档移除' const actionLabel = pending ? '移除待上传' : '从文档移除'
const handler = pending ? '__removePendingAttachment' : '__removeCurrentAttachment' const handler = pending ? '__removePendingAttachment' : '__removeCurrentAttachment'
return '<div class="file-card">' return '<div class="file-card">'
+ '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div></div>' + '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div></div>'
+ previewHtml
+ '<div class="file-meta">' + meta + '</div>' + '<div class="file-meta">' + meta + '</div>'
+ '<div class="file-actions">' + '<div class="file-actions">'
+ linkHtml + linkHtml
@@ -726,25 +853,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function renderAttachmentEditors() { function renderAttachmentEditors() {
renderAttachmentCards(imageCurrentListEl, state.currentImageAttachments, 'image', false) renderAttachmentCards(imageCurrentListEl, state.currentImageAttachments, 'image', false)
renderAttachmentCards(videoCurrentListEl, state.currentVideoAttachments, 'video', false) renderAttachmentCards(videoCurrentListEl, state.currentVideoAttachments, 'video', false)
renderAttachmentCards(documentFileCurrentListEl, state.currentFileAttachments, 'file', false)
renderAttachmentCards(imagePendingListEl, state.pendingImageFiles, 'image', true) renderAttachmentCards(imagePendingListEl, state.pendingImageFiles, 'image', true)
renderAttachmentCards(videoPendingListEl, state.pendingVideoFiles, 'video', true) renderAttachmentCards(videoPendingListEl, state.pendingVideoFiles, 'video', true)
renderAttachmentCards(documentFilePendingListEl, state.pendingDocumentFiles, 'file', true)
} }
function renderLinks(item) { function renderLinks(item) {
const links = [] 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 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 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) { for (let i = 0; i < imageUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(imageUrls[i]) + '" target="_blank" rel="noreferrer">图片流' + (i + 1) + '</a>') 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) { for (let i = 0; i < videoUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(videoUrls[i]) + '" target="_blank" rel="noreferrer">视频流' + (i + 1) + '</a>') 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) { if (!links.length) {
return '<span class="muted">无</span>' return '<span class="muted">无</span>'
} }
return '<div class="doc-links">' + links.join('') + '</div>' return '<div class="doc-links">' + links.join('') + '</div>'
+ (imageThumbs.length ? '<div class="thumb-strip">' + imageThumbs.join('') + '</div>' : '')
} }
function renderTable() { function renderTable() {
@@ -768,17 +904,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
} }
function appendPendingFiles(category, fileList) { 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 || []) const files = Array.from(fileList || [])
for (let i = 0; i < files.length; i += 1) { for (let i = 0; i < files.length; i += 1) {
target.push({ target.push({
key: Date.now() + '-' + Math.random().toString(36).slice(2), key: Date.now() + '-' + Math.random().toString(36).slice(2),
file: files[i], file: files[i],
previewUrl: category === 'image' && files[i] ? URL.createObjectURL(files[i]) : '',
}) })
} }
renderAttachmentEditors() 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) { function bindDropzone(dropzoneEl, inputEl, category) {
if (!dropzoneEl || !inputEl) { if (!dropzoneEl || !inputEl) {
return return
@@ -818,15 +971,20 @@ routerAdd('GET', '/manage/document-manage', function (e) {
} }
function removePendingAttachment(category, index) { 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) { if (index >= 0 && index < target.length) {
revokePendingPreview(target[index])
target.splice(index, 1) target.splice(index, 1)
} }
renderAttachmentEditors() renderAttachmentEditors()
} }
function removeCurrentAttachment(category, index) { 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) { if (index < 0 || index >= target.length) {
return return
} }
@@ -840,6 +998,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
async function loadDocuments() { async function loadDocuments() {
setStatus('正在加载文档列表...', '') setStatus('正在加载文档列表...', '')
showLoading('正在加载文档列表...')
try { try {
const data = await requestJson('/document/list', {}) const data = await requestJson('/document/list', {})
state.list = data.items || [] state.list = data.items || []
@@ -847,6 +1006,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success') setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '加载列表失败', 'error') setStatus(err.message || '加载列表失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -864,6 +1025,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.documentRemark.value = '' fields.documentRemark.value = ''
fields.imageFile.value = '' fields.imageFile.value = ''
fields.videoFile.value = '' fields.videoFile.value = ''
fields.documentFile.value = ''
state.selections.documentTypeSource = '' state.selections.documentTypeSource = ''
state.selections.documentTypeValues = [] state.selections.documentTypeValues = []
state.selections.documentKeywords = [] state.selections.documentKeywords = []
@@ -875,13 +1037,37 @@ routerAdd('GET', '/manage/document-manage', function (e) {
} }
function enterCreateMode() { function enterCreateMode() {
clearPendingList(state.pendingImageFiles)
clearPendingList(state.pendingVideoFiles)
clearPendingList(state.pendingDocumentFiles)
state.mode = 'create' state.mode = 'create'
state.editingId = '' state.editingId = ''
state.editingSource = null state.editingSource = null
state.currentImageAttachments = [] state.currentImageAttachments = []
state.currentVideoAttachments = [] state.currentVideoAttachments = []
state.currentFileAttachments = []
state.pendingImageFiles = [] state.pendingImageFiles = []
state.pendingVideoFiles = [] 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 = [] state.removedAttachmentIds = []
resetForm() resetForm()
updateEditorMode() updateEditorMode()
@@ -902,6 +1088,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.documentRemark.value = item.document_remark || '' fields.documentRemark.value = item.document_remark || ''
fields.imageFile.value = '' fields.imageFile.value = ''
fields.videoFile.value = '' fields.videoFile.value = ''
fields.documentFile.value = ''
const documentTypeParts = splitPipeValue(item.document_type) const documentTypeParts = splitPipeValue(item.document_type)
const firstDocumentType = documentTypeParts.length ? documentTypeParts[0] : '' const firstDocumentType = documentTypeParts.length ? documentTypeParts[0] : ''
@@ -936,8 +1123,10 @@ routerAdd('GET', '/manage/document-manage', function (e) {
state.editingSource = target state.editingSource = target
state.currentImageAttachments = normalizeAttachmentList(target.document_image_attachments, target.document_image_ids, target.document_image_urls) 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.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.pendingImageFiles = []
state.pendingVideoFiles = [] state.pendingVideoFiles = []
state.pendingDocumentFiles = []
state.removedAttachmentIds = [] state.removedAttachmentIds = []
fillFormFromItem(target) fillFormFromItem(target)
@@ -947,7 +1136,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function buildMutationPayload(imageAttachments, videoAttachments) { function buildMutationPayload(imageAttachments, videoAttachments, fileAttachments) {
const source = state.editingSource || {} const source = state.editingSource || {}
return { return {
document_id: state.mode === 'edit' ? state.editingId : '', 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_content: fields.documentContent.value.trim(),
document_image: imageAttachments.map(function (item) { return item.attachments_id }), document_image: imageAttachments.map(function (item) { return item.attachments_id }),
document_video: videoAttachments.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_relation_model: fields.relationModel.value.trim(),
document_keywords: joinPipeValue(state.selections.documentKeywords), document_keywords: joinPipeValue(state.selections.documentKeywords),
document_share_count: typeof source.document_share_count === 'undefined' || source.document_share_count === null ? '' : source.document_share_count, 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' ? '正在保存文档修改...' : '正在上传附件并创建文档...', '') setStatus(state.mode === 'edit' ? '正在保存文档修改...' : '正在上传附件并创建文档...', '')
showLoading(state.mode === 'edit' ? '正在保存文档修改,请稍候...' : '正在上传附件并创建文档,请稍候...')
const uploadedAttachments = [] const uploadedAttachments = []
try { try {
const newImageAttachments = await uploadPendingFiles(state.pendingImageFiles, 'image') const newImageAttachments = await uploadPendingFiles(state.pendingImageFiles, 'image')
const newVideoAttachments = await uploadPendingFiles(state.pendingVideoFiles, 'video') const newVideoAttachments = await uploadPendingFiles(state.pendingVideoFiles, 'video')
const newFileAttachments = await uploadPendingFiles(state.pendingDocumentFiles, 'file')
uploadedAttachments.push.apply(uploadedAttachments, newImageAttachments) uploadedAttachments.push.apply(uploadedAttachments, newImageAttachments)
uploadedAttachments.push.apply(uploadedAttachments, newVideoAttachments) uploadedAttachments.push.apply(uploadedAttachments, newVideoAttachments)
uploadedAttachments.push.apply(uploadedAttachments, newFileAttachments)
const finalImageAttachments = state.currentImageAttachments.concat(newImageAttachments) const finalImageAttachments = state.currentImageAttachments.concat(newImageAttachments)
const finalVideoAttachments = state.currentVideoAttachments.concat(newVideoAttachments) 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') { if (state.mode === 'edit') {
await requestJson('/document/update', payload) const updated = await requestJson('/document/update', payload)
const deleteFailed = await deleteRemovedAttachments() const deleteFailed = await deleteRemovedAttachments()
await loadDocuments() await loadDocuments()
enterCreateMode() state.editingId = (updated && updated.document_id) || state.editingId
enterEditMode(state.editingId)
if (deleteFailed.length) { if (deleteFailed.length) {
setStatus('文档已更新,但以下附件删除失败:' + deleteFailed.join(''), 'error') setStatus('文档已更新,但以下附件删除失败:' + deleteFailed.join(''), 'error')
return return
@@ -1037,15 +1232,19 @@ routerAdd('GET', '/manage/document-manage', function (e) {
return return
} }
await requestJson('/document/create', payload) const created = await requestJson('/document/create', payload)
await loadDocuments() await loadDocuments()
enterCreateMode() if (created && created.document_id) {
enterEditMode(created.document_id)
}
setStatus('文档创建成功。', 'success') setStatus('文档创建成功。', 'success')
} catch (err) { } catch (err) {
if (uploadedAttachments.length) { if (uploadedAttachments.length) {
await cleanupUploadedAttachments(uploadedAttachments) await cleanupUploadedAttachments(uploadedAttachments)
} }
setStatus(err.message || (state.mode === 'edit' ? '修改文档失败' : '创建文档失败'), 'error') setStatus(err.message || (state.mode === 'edit' ? '修改文档失败' : '创建文档失败'), 'error')
} finally {
hideLoading()
} }
} }
@@ -1056,6 +1255,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
} }
setStatus('正在删除文档...', '') setStatus('正在删除文档...', '')
showLoading('正在删除文档,请稍候...')
try { try {
await requestJson('/document/delete', { document_id: target }) await requestJson('/document/delete', { document_id: target })
if (state.mode === 'edit' && state.editingId === target) { if (state.mode === 'edit' && state.editingId === target) {
@@ -1065,6 +1265,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
setStatus('文档删除成功。', 'success') setStatus('文档删除成功。', 'success')
} catch (err) { } catch (err) {
setStatus(err.message || '删除文档失败', 'error') setStatus(err.message || '删除文档失败', 'error')
} finally {
hideLoading()
} }
} }
@@ -1072,6 +1274,13 @@ routerAdd('GET', '/manage/document-manage', function (e) {
window.__editDocument = function (documentId) { window.__editDocument = function (documentId) {
enterEditMode(decodeURIComponent(documentId)) enterEditMode(decodeURIComponent(documentId))
} }
window.__previewImage = function (url) {
if (!url) {
return
}
imageViewerImgEl.src = url
imageViewerEl.classList.add('show')
}
window.__removePendingAttachment = removePendingAttachment window.__removePendingAttachment = removePendingAttachment
window.__removeCurrentAttachment = removeCurrentAttachment window.__removeCurrentAttachment = removeCurrentAttachment
@@ -1115,8 +1324,24 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}) })
document.getElementById('submitBtn').addEventListener('click', submitDocument) document.getElementById('submitBtn').addEventListener('click', submitDocument)
document.getElementById('cancelEditBtn').addEventListener('click', function () { document.getElementById('cancelEditBtn').addEventListener('click', function () {
enterCreateMode() enterIdleMode()
setStatus('已取消编辑。', 'success') 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 () { document.getElementById('resetBtn').addEventListener('click', function () {
if (state.mode === 'edit' && state.editingSource) { if (state.mode === 'edit' && state.editingSource) {
@@ -1148,10 +1373,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
appendPendingFiles('video', event.target.files) appendPendingFiles('video', event.target.files)
fields.videoFile.value = '' fields.videoFile.value = ''
}) })
fields.documentFile.addEventListener('change', function (event) {
appendPendingFiles('file', event.target.files)
fields.documentFile.value = ''
})
bindDropzone(imageDropzoneEl, fields.imageFile, 'image') bindDropzone(imageDropzoneEl, fields.imageFile, 'image')
bindDropzone(videoDropzoneEl, fields.videoFile, 'video') bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
bindDropzone(documentFileDropzoneEl, fields.documentFile, 'file')
enterCreateMode() enterIdleMode()
;(async function initPage() { ;(async function initPage() {
try { try {
await loadDictionaries() await loadDictionaries()

View File

@@ -39,6 +39,10 @@ routerAdd('GET', '/manage', function (e) {
<h2>文档管理</h2> <h2>文档管理</h2>
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a> <a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
</article> </article>
<article class="card">
<h2>SDK 权限管理</h2>
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
</article>
</div> </div>
<div class="actions"> <div class="actions">
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button> <button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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)
})

View File

@@ -57,6 +57,28 @@
- 已将 schema 脚本中的 `user_id` 改为非必填。 - 已将 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` - 自动写入 `attachments_owner = 当前用户 openid`
- `POST /pb/api/attachment/delete` - `POST /pb/api/attachment/delete`
-`attachments_id` 真删除附件 -`attachments_id` 真删除附件
- 若该附件已被 `tbl_document.document_image``document_video` 中的任一附件列表引用,则拒绝删除 - 若该附件已被 `tbl_document.document_image``document_video``document_file` 中的任一附件列表引用,则拒绝删除
说明: 说明:
@@ -258,8 +280,10 @@
- 额外补充: - 额外补充:
- `document_image_urls` - `document_image_urls`
- `document_video_urls` - `document_video_urls`
- `document_file_urls`
- `document_image_attachments` - `document_image_attachments`
- `document_video_attachments` - `document_video_attachments`
- `document_file_attachments`
- `POST /pb/api/document/detail` - `POST /pb/api/document/detail`
-`document_id` 查询单条文档 -`document_id` 查询单条文档
- 返回与附件表联动解析后的多文件流链接 - 返回与附件表联动解析后的多文件流链接
@@ -267,7 +291,7 @@
- 新增文档 - 新增文档
- `document_id` 可不传,由服务端自动生成 - `document_id` 可不传,由服务端自动生成
- `document_title``document_type` 为必填;其余字段均允许为空 - `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_type` 前端从单个字典来源中多选枚举值,最终按 `system_dict_id@dict_word_enum|...` 保存
- `document_keywords``document_product_categories``document_application_scenarios``document_hotel_type` 统一从固定字典多选并按 `|` 保存 - `document_keywords``document_product_categories``document_application_scenarios``document_hotel_type` 统一从固定字典多选并按 `|` 保存
- 其中 `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` 反查实际文件,并返回可直接访问的数据流链接数组。 - 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接数组。
- `document_owner` 语义为“上传者 openid”。 - `document_owner` 语义为“上传者 openid”。
@@ -337,8 +361,8 @@
- 返回主页 - 返回主页
- 文档管理页支持: - 文档管理页支持:
- 先上传附件到 `tbl_attachments` - 先上传附件到 `tbl_attachments`
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video` - 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video` / `document_file`
- 图片视频都支持多选上传 - 图片视频、文件都支持多选上传
- 新增文档 - 新增文档
- 编辑已有文档并回显多图片、多视频 - 编辑已有文档并回显多图片、多视频
- 从文档中移除附件并在保存后删除对应附件记录 - 从文档中移除附件并在保存后删除对应附件记录

View File

@@ -130,6 +130,28 @@ components:
type: string type: string
users_picture: users_picture:
type: string 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: openid:
type: string type: string
description: "全平台统一身份标识;微信用户为微信 openid平台用户为服务端生成的 GUID" description: "全平台统一身份标识;微信用户为微信 openid平台用户为服务端生成的 GUID"
@@ -190,6 +212,13 @@ components:
users_auth_type: 0 users_auth_type: 0
users_type: 注册用户 users_type: 注册用户
users_picture: '' 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 openid: app_momo
company_id: '' company_id: ''
users_parent_id: '' users_parent_id: ''
@@ -221,7 +250,17 @@ components:
example: 2b7d9f2e3c4a5b6d7e8f example: 2b7d9f2e3c4a5b6d7e8f
users_picture: users_picture:
type: string 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: WechatProfileResponseData:
type: object type: object
properties: properties:
@@ -249,9 +288,19 @@ components:
example: 12345678 example: 12345678
users_picture: users_picture:
type: string type: string
example: https://example.com/avatar.png description: "用户头像附件的 `attachments_id`"
example: ATT-1743123456789-abc123
users_id_number: users_id_number:
type: string 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: users_level:
type: string type: string
users_type: users_type:
@@ -537,6 +586,30 @@ components:
anyOf: anyOf:
- $ref: '#/components/schemas/AttachmentRecord' - $ref: '#/components/schemas/AttachmentRecord'
- type: 'null' - 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: document_owner:
type: string type: string
description: "上传者 openid" description: "上传者 openid"
@@ -641,6 +714,14 @@ components:
items: items:
type: string type: string
description: "视频附件 id 列表;支持数组或 `|` 分隔字符串" description: "视频附件 id 列表;支持数组或 `|` 分隔字符串"
document_file:
oneOf:
- type: string
description: "多个文件附件 id 使用 `|` 分隔"
- type: array
items:
type: string
description: "文件附件 id 列表;支持数组或 `|` 分隔字符串"
document_relation_model: document_relation_model:
type: string type: string
document_keywords: document_keywords:
@@ -1144,7 +1225,7 @@ paths:
summary: 删除附件 summary: 删除附件
description: | description: |
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 引用,则拒绝删除。 按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 或 `document_file` 引用,则拒绝删除。
requestBody: requestBody:
required: true required: true
content: content:
@@ -1185,8 +1266,8 @@ paths:
description: | description: |
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。 支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments` 返回结果会自动根据 `document_image`、`document_video`、`document_file` 中的多个 `attachments_id` 关联 `tbl_attachments`
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。 额外补充 `document_image_urls`、`document_video_urls`、`document_file_urls` 以及对应附件对象数组。
requestBody: requestBody:
required: false required: false
content: content:
@@ -1259,7 +1340,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
`document_id` 可选;未传时服务端自动生成。 `document_id` 可选;未传时服务端自动生成。
`document_title`、`document_type` 为必填;其余字段均允许为空。 `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`。 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
requestBody: requestBody:
required: true required: true
@@ -1297,7 +1378,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
按 `document_id` 定位现有文档并更新。 按 `document_id` 定位现有文档并更新。
`document_title`、`document_type` 为必填;其余字段均允许为空。 `document_title`、`document_type` 为必填;其余字段均允许为空。
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。 若传入 `document_image`、`document_video`、`document_file`,则支持多个 `attachments_id`,并会逐一校验是否存在。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
requestBody: requestBody:
required: true required: true

View 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

View File

@@ -297,6 +297,28 @@ components:
type: string type: string
users_picture: users_picture:
type: string 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: openid:
type: string type: string
description: 全平台统一身份标识 description: 全平台统一身份标识
@@ -338,7 +360,17 @@ components:
example: 2b7d9f2e3c4a5b6d7e8f example: 2b7d9f2e3c4a5b6d7e8f
users_picture: users_picture:
type: string 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: SystemRefreshTokenRequest:
type: object type: object
properties: properties:

View File

@@ -120,6 +120,28 @@ components:
type: string type: string
users_picture: users_picture:
type: string 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: openid:
type: string type: string
description: 全平台统一身份标识;微信用户为微信 openid平台用户为服务端生成的 GUID description: 全平台统一身份标识;微信用户为微信 openid平台用户为服务端生成的 GUID
@@ -180,6 +202,13 @@ components:
users_auth_type: 0 users_auth_type: 0
users_type: 注册用户 users_type: 注册用户
users_picture: '' 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 openid: app_momo
company_id: '' company_id: ''
users_parent_id: '' users_parent_id: ''
@@ -211,7 +240,17 @@ components:
example: 2b7d9f2e3c4a5b6d7e8f example: 2b7d9f2e3c4a5b6d7e8f
users_picture: users_picture:
type: string 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: WechatProfileResponseData:
type: object type: object
properties: properties:
@@ -239,9 +278,19 @@ components:
example: 12345678 example: 12345678
users_picture: users_picture:
type: string type: string
example: https://example.com/avatar.png description: 用户头像附件的 `attachments_id`
example: ATT-1743123456789-abc123
users_id_number: users_id_number:
type: string 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: users_level:
type: string type: string
users_type: users_type:
@@ -480,52 +529,76 @@ components:
type: string type: string
document_image: document_image:
type: string type: string
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存 description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
document_image_ids: document_image_ids:
type: array type: array
description: `document_image` 解析后的附件 id 列表 description: "`document_image` 解析后的附件 id 列表"
items: items:
type: string type: string
document_image_urls: document_image_urls:
type: array type: array
description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表 description: "根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表"
items: items:
type: string type: string
document_image_url: document_image_url:
type: string type: string
description: 兼容字段,返回第一张图片的文件流链接 description: "兼容字段,返回第一张图片的文件流链接"
document_image_attachments: document_image_attachments:
type: array type: array
items: items:
$ref: '#/components/schemas/AttachmentRecord' $ref: '#/components/schemas/AttachmentRecord'
document_image_attachment: document_image_attachment:
allOf: anyOf:
- $ref: '#/components/schemas/AttachmentRecord' - $ref: '#/components/schemas/AttachmentRecord'
nullable: true - type: 'null'
document_video: document_video:
type: string type: string
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存 description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
document_video_ids: document_video_ids:
type: array type: array
description: `document_video` 解析后的附件 id 列表 description: "`document_video` 解析后的附件 id 列表"
items: items:
type: string type: string
document_video_urls: document_video_urls:
type: array type: array
description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表 description: "根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表"
items: items:
type: string type: string
document_video_url: document_video_url:
type: string type: string
description: 兼容字段,返回第一个视频的文件流链接 description: "兼容字段,返回第一个视频的文件流链接"
document_video_attachments: document_video_attachments:
type: array type: array
items: items:
$ref: '#/components/schemas/AttachmentRecord' $ref: '#/components/schemas/AttachmentRecord'
document_video_attachment: document_video_attachment:
allOf: anyOf:
- $ref: '#/components/schemas/AttachmentRecord' - $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: document_owner:
type: string type: string
description: 上传者 openid description: 上传者 openid
@@ -630,6 +703,14 @@ components:
items: items:
type: string type: string
description: 视频附件 id 列表;支持数组或 `|` 分隔字符串 description: 视频附件 id 列表;支持数组或 `|` 分隔字符串
document_file:
oneOf:
- type: string
description: 多个文件附件 id 使用 `|` 分隔
- type: array
items:
type: string
description: 文件附件 id 列表;支持数组或 `|` 分隔字符串
document_relation_model: document_relation_model:
type: string type: string
document_keywords: document_keywords:
@@ -1237,7 +1318,7 @@ paths:
summary: 删除附件 summary: 删除附件
description: | description: |
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 引用,则拒绝删除。 按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 或 `document_file` 引用,则拒绝删除。
requestBody: requestBody:
required: true required: true
content: content:
@@ -1278,8 +1359,8 @@ paths:
description: | description: |
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。 支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments` 返回结果会自动根据 `document_image`、`document_video`、`document_file` 中的多个 `attachments_id` 关联 `tbl_attachments`
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。 额外补充 `document_image_urls`、`document_video_urls`、`document_file_urls` 以及对应附件对象数组。
requestBody: requestBody:
required: false required: false
content: content:
@@ -1352,7 +1433,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
`document_id` 可选;未传时服务端自动生成。 `document_id` 可选;未传时服务端自动生成。
`document_title`、`document_type` 为必填;其余字段均允许为空。 `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`。 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
requestBody: requestBody:
required: true required: true
@@ -1390,7 +1471,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。 仅允许 `ManagePlatform` 用户访问。
按 `document_id` 定位现有文档并更新。 按 `document_id` 定位现有文档并更新。
`document_title`、`document_type` 为必填;其余字段均允许为空。 `document_title`、`document_type` 为必填;其余字段均允许为空。
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。 若传入 `document_image`、`document_video`、`document_file`,则支持多个 `attachments_id`,并会逐一校验是否存在。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。 成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
requestBody: requestBody:
required: true required: true

View File

@@ -82,10 +82,10 @@
| company_id | text | 公司id (存储 tbl_company.company_id) | | company_id | text | 公司id (存储 tbl_company.company_id) |
| users_parent_id | text | 用户父级id (存储 tbl_users.users_id) | | users_parent_id | text | 用户父级id (存储 tbl_users.users_id) |
| users_promo_code | text | 用户推广码 | | users_promo_code | text | 用户推广码 |
| users_id_pic_a | file | 用户证件照片(正) | | users_id_pic_a | text | 用户证件照片(正),保存 tbl_attachments.attachments_id |
| users_id_pic_b | file | 用户证件照片(反) | | users_id_pic_b | text | 用户证件照片(反),保存 tbl_attachments.attachments_id |
| users_title_picture | file | 用户资质照片 | | users_title_picture | text | 用户资质照片,保存 tbl_attachments.attachments_id |
| users_picture | file | 用户头像 | | users_picture | text | 用户头像,保存 tbl_attachments.attachments_id |
| usergroups_id | text | 用户组id (存储 tbl_user_groups.usergroups_id) | | usergroups_id | text | 用户组id (存储 tbl_user_groups.usergroups_id) |
**索引规划 (Indexes):** **索引规划 (Indexes):**
@@ -93,5 +93,3 @@
* `CREATE UNIQUE INDEX` 针对 `users_phone` (确保手机号唯一,加速登录查询) * `CREATE UNIQUE INDEX` 针对 `users_phone` (确保手机号唯一,加速登录查询)
* `CREATE UNIQUE INDEX` 针对 `users_wx_openid` (确保微信开放ID唯一) * `CREATE UNIQUE INDEX` 针对 `users_wx_openid` (确保微信开放ID唯一)
* `CREATE INDEX` 针对 `company_id`, `usergroups_id`, `users_parent_id` (加速这三个高频业务外键的匹配查询) * `CREATE INDEX` 针对 `company_id`, `usergroups_id`, `users_parent_id` (加速这三个高频业务外键的匹配查询)

View File

@@ -7,7 +7,8 @@
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"init:newpb": "node pocketbase.newpb.js", "init:newpb": "node pocketbase.newpb.js",
"init:documents": "node pocketbase.documents.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": [], "keywords": [],
"author": "", "author": "",

View File

@@ -24,6 +24,8 @@ const collections = [
{ {
name: 'tbl_attachments', name: 'tbl_attachments',
type: 'base', type: 'base',
listRule: '',
viewRule: '',
fields: [ fields: [
{ name: 'attachments_id', type: 'text', required: true }, { name: 'attachments_id', type: 'text', required: true },
{ name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 4294967296, mimeTypes: [] }, { name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 4294967296, mimeTypes: [] },
@@ -56,6 +58,7 @@ const collections = [
{ name: 'document_content', type: 'text' }, { name: 'document_content', type: 'text' },
{ name: 'document_image', type: 'text' }, { name: 'document_image', type: 'text' },
{ name: 'document_video', type: 'text' }, { name: 'document_video', type: 'text' },
{ name: 'document_file', type: 'text' },
{ name: 'document_owner', type: 'text' }, { name: 'document_owner', type: 'text' },
{ name: 'document_relation_model', type: 'text' }, { name: 'document_relation_model', type: 'text' },
{ name: 'document_keywords', type: 'text' }, { name: 'document_keywords', type: 'text' },
@@ -135,6 +138,11 @@ function buildCollectionPayload(collectionData, existingCollection) {
return { return {
name: collectionData.name, name: collectionData.name,
type: collectionData.type, 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)), fields: collectionData.fields.map((field) => normalizeFieldPayload(field, null)),
indexes: collectionData.indexes, indexes: collectionData.indexes,
}; };
@@ -158,6 +166,11 @@ function buildCollectionPayload(collectionData, existingCollection) {
return { return {
name: collectionData.name, name: collectionData.name,
type: collectionData.type, 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, fields: fields,
indexes: collectionData.indexes, indexes: collectionData.indexes,
}; };

View 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();

View File

@@ -81,10 +81,10 @@ const collections = [
{ name: 'company_id', type: 'text' }, { name: 'company_id', type: 'text' },
{ name: 'users_parent_id', type: 'text' }, { name: 'users_parent_id', type: 'text' },
{ name: 'users_promo_code', 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_a', type: 'text' },
{ name: 'users_id_pic_b', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, { name: 'users_id_pic_b', type: 'text' },
{ name: 'users_title_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, { name: 'users_title_picture', type: 'text' },
{ name: 'users_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, { name: 'users_picture', type: 'text' },
{ name: 'usergroups_id', type: 'text' } { name: 'usergroups_id', type: 'text' }
], ],
indexes: [ indexes: [

View File

@@ -56,9 +56,9 @@ const collections = [
{ name: 'company_id', type: 'text' }, { name: 'company_id', type: 'text' },
{ name: 'users_parent_id', type: 'text' }, { name: 'users_parent_id', type: 'text' },
{ name: 'users_promo_code', 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_a', type: 'text' },
{ name: 'users_id_pic_b', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, { name: 'users_id_pic_b', type: 'text' },
{ name: 'users_title_picture', type: 'file', options: { maxSelect: 1, mimeTypes: ['image/jpeg', 'image/png', 'image/webp'] } }, { name: 'users_title_picture', type: 'text' },
{ name: 'users_picture', type: 'text' }, { name: 'users_picture', type: 'text' },
{ name: 'usergroups_id', type: 'text' } { name: 'usergroups_id', type: 'text' }
], ],