Compare commits
45 Commits
c9aa4c75ae
...
dev/2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e263a616f6 | |||
| ed5181b382 | |||
| 95375d5476 | |||
| b34003e2c0 | |||
| 3a455bd644 | |||
| 8103f4c897 | |||
| 0066a46037 | |||
| 0b62de8c6b | |||
| 9097658782 | |||
| ee6c777fb1 | |||
| 5503758854 | |||
| 8d84306747 | |||
| c45f3e69da | |||
| 80428e625f | |||
| a06ee95482 | |||
| b4aed459c5 | |||
| aa03abf651 | |||
| af23505a74 | |||
| 3898518ad0 | |||
| f0d3d1986f | |||
| 2750d8012f | |||
| 89fbfcaca6 | |||
| 80c172ed77 | |||
| a0ddcd673d | |||
| c50fab3d3a | |||
| 578d914e9b | |||
| 1b194bf3e1 | |||
| b685a91598 | |||
| 7da168e1b2 | |||
| 179a9089b7 | |||
| 57a0bb9b5a | |||
| 4cfbfa7d8f | |||
| eb189ae9f7 | |||
| d489f352db | |||
| 7dbf455341 | |||
| 2416eab34f | |||
| c4f2cea1a9 | |||
| 227ab42e5a | |||
| 4cf19e417f | |||
| 97f6971bd0 | |||
| 526c813d8d | |||
| 29280f6f57 | |||
| 1c2fee28ec | |||
| 5b9114aeac | |||
| 2dfb2e0316 |
@@ -1,155 +0,0 @@
|
||||
---
|
||||
name: "OPSX: Apply"
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). 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")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- 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:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- 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 `/opsx:continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `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! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**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
|
||||
@@ -1,160 +0,0 @@
|
||||
---
|
||||
name: "OPSX: Archive"
|
||||
description: Archive a completed change in the experimental workflow
|
||||
category: Workflow
|
||||
tags: [workflow, archive, experimental]
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). 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
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- 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
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta 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 an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/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 `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**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 the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
name: "OPSX: Explore"
|
||||
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||
category: Workflow
|
||||
tags: [workflow, explore, experimental, thinking]
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### 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. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 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 things crystallize, you might offer a summary - but it's 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
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: "OPSX: Propose"
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
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 argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no 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 in the planning home resolved by the CLI 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
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
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
|
||||
- `resolvedOutputPath`: Resolved path or pattern 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 and write it to `resolvedOutputPath`
|
||||
- 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` to start implementing."
|
||||
|
||||
**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,143 +0,0 @@
|
||||
---
|
||||
name: "OPSX: Sync"
|
||||
description: Sync delta specs from a change to main specs
|
||||
category: Workflow
|
||||
tags: [workflow, specs, experimental]
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). 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 changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -1,159 +0,0 @@
|
||||
---
|
||||
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.4.1"
|
||||
---
|
||||
|
||||
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")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- 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:
|
||||
- `contextFiles`: artifact ID -> array of concrete 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
|
||||
|
||||
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `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
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
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.4.1"
|
||||
---
|
||||
|
||||
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
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
|
||||
|
||||
**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**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta 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 an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/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 `changeRoot` to the archive directory
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/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:** the archive path derived from `planningHome.changesDir`/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
|
||||
@@ -1,287 +0,0 @@
|
||||
---
|
||||
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.4.1"
|
||||
---
|
||||
|
||||
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. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
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.4.1"
|
||||
---
|
||||
|
||||
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 in the planning home resolved by the CLI 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
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
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
|
||||
- `resolvedOutputPath`: Resolved path or pattern 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 and write it to `resolvedOutputPath`
|
||||
- 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,147 +0,0 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.4.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**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 changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Resolve change context**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
|
||||
|
||||
3. **Find delta specs**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
4. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each repo-local capability delta spec path returned by the CLI:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
5. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
33
.github/workflows/i18n-audit.yml
vendored
33
.github/workflows/i18n-audit.yml
vendored
@@ -1,33 +0,0 @@
|
||||
# GitHub Actions 示例:i18n audit on PR
|
||||
name: i18n-audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'lang/**'
|
||||
- 'lang-bundle.json'
|
||||
- '**/*.vue'
|
||||
- '**/*.js'
|
||||
- 'scripts/i18n_audit.py'
|
||||
- 'scripts/audit_lang_bundle_v2.py'
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run i18n audit
|
||||
run: |
|
||||
python scripts/i18n_audit.py --strict
|
||||
- name: Upload audit report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-audit-report
|
||||
path: scripts/lang_audit_report_v2.json
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,14 +1,5 @@
|
||||
/unpackage
|
||||
/.hbuilderx
|
||||
/.idea
|
||||
/.logs
|
||||
/node_modules
|
||||
/iconfont-preview.html
|
||||
|
||||
.codebuddy
|
||||
.vscode
|
||||
.trae
|
||||
|
||||
# 鸿蒙元服务构建产物
|
||||
stub-app-projects/mp-harmony/ascf/ascf_src
|
||||
stub-app-projects/mp-harmony/ascf/ascf_build
|
||||
stub-app-projects/mp-harmony/entry/src/main/resources
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version" : "1.0",
|
||||
"configurations" : [
|
||||
{
|
||||
"openVueDevtools" : true,
|
||||
"type" : "uni-app:h5"
|
||||
},
|
||||
{
|
||||
"playground" : "standard",
|
||||
"type" : "uni-app:app-android"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# 自动 hook:i18n 完整性。
|
||||
# 在 commit 前,如果改动触及 lang/、使用 $lang() 的 .vue/.js,或 lang-bundle.json,
|
||||
# 就跑一次审计,真问题会拦下 commit。
|
||||
#
|
||||
# 启用方法:
|
||||
# 1) 项目根已有 .husky/pre-commit(本脚本)
|
||||
# 2) 把这个 commit-msg / pre-commit 加入 husky 钩子(本仓库用 simple-git-hooks
|
||||
# 或 husky 都行,本脚本不依赖特定框架)
|
||||
#
|
||||
# 也可以在 CI 跑:
|
||||
# python scripts/i18n_audit.py --strict
|
||||
|
||||
set -e
|
||||
|
||||
# 检测本次 diff 是否触动相关文件
|
||||
changed=$(git diff --cached --name-only HEAD)
|
||||
|
||||
relevant=$(echo "$changed" | grep -E '(lang/|lang-bundle\.json|\.vue$|\.js$)' | grep -v 'scripts/i18n_audit\|scripts/audit\|scripts/I18N_AUDIT' || true)
|
||||
|
||||
if [ -z "$relevant" ]; then
|
||||
echo "[pre-commit] No lang-touching files; skip i18n audit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[pre-commit] Running i18n audit on changed files:"
|
||||
echo "$relevant" | sed 's/^/ /'
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
if python scripts/i18n_audit.py --strict; then
|
||||
echo "[pre-commit] i18n OK"
|
||||
exit 0
|
||||
else
|
||||
echo "[pre-commit] i18n FAILED — 修复方法见 scripts/I18N_AUDIT.md"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[pre-commit] python not available; skipping"
|
||||
exit 0
|
||||
fi
|
||||
167
.local.config.js
167
.local.config.js
@@ -1,167 +0,0 @@
|
||||
// 本地调试配置示例文件
|
||||
// 复制此文件并重命名为 local.config.js 以使用自定义本地配置
|
||||
|
||||
const localDevConfig = ({
|
||||
'460': { // 制氧设备平台
|
||||
uniacid: 460,
|
||||
domain: 'https://xcx30.5g-quickapp.com/',
|
||||
},
|
||||
'576-xcx30.5g': { // 活性石灰装备
|
||||
uniacid: 576,
|
||||
domain: 'https://xcx30.5g-quickapp.com/',
|
||||
},
|
||||
'2285': { // 数码喷墨墨水
|
||||
uniacid: 2285,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2811': { // POCT检测分析平台
|
||||
uniacid: 2811,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2881': { // 防爆电器
|
||||
uniacid: 2881,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2724': { // 生物菌肥
|
||||
uniacid: 2724,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2505': { // 煤矿钻机
|
||||
uniacid: 2505,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2777': { // 养老服务
|
||||
uniacid: 2777,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'1': { // 开发平台
|
||||
uniacid: 1,
|
||||
domain: 'https://dev.aigc-quickapp.com',
|
||||
},
|
||||
'1-test': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'11-test': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'18-test': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'local-2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://192.168.197.1:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-11': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-18': { // 人工晶体
|
||||
uniacid: 18,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'100': {
|
||||
uniacid: 100,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'1': {
|
||||
uniacid: 1,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'115': {
|
||||
uniacid: 115,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-11': {
|
||||
uniacid: 11,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-206': {
|
||||
uniacid: 206,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'47': { // 修改弹窗
|
||||
uniacid: 47,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'145': { // 表单链接转义问题
|
||||
uniacid: 145,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-213': { // 单图组点击链接无法跳转
|
||||
uniacid: 213,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-4': { // 公告点击链接无法跳转
|
||||
uniacid: 4,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
uniacid: 97,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-50': { // 燃气安全产品机器人,购物车无反应
|
||||
uniacid: 50,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-237': { // 商品分类,没有立即咨询文字
|
||||
uniacid: 237,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2847': { //
|
||||
uniacid: 2847,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2482': { // 低空经济
|
||||
uniacid: 2482,
|
||||
domain: 'https://xcx3.aigc-quickapp.com/',
|
||||
},
|
||||
'2485': { // 验证电子表单前后端数据一致性
|
||||
uniacid: 2485,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2821': { // 离型纸硅油纸
|
||||
uniacid: 2821,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'18': { // 测试平台
|
||||
uniacid: 18,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'48': { // 测试平台
|
||||
uniacid: 48,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'54': { // 测试平台
|
||||
uniacid: 54,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
})['2821']; // 离型纸硅油纸
|
||||
// })['2482']; // 低空经济
|
||||
// })['48']; // 离型纸硅油纸
|
||||
// })['2485']; // 验证电子表单前后端数据一致性
|
||||
// })['5g-11']; // 数码
|
||||
// })['5g-213']; // 选择要使用的环境配置
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['local-1']; // 本地
|
||||
// })['local-18']; // 本地-人工晶体-有商品
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
// })['18-test']; // test
|
||||
export default localDevConfig;
|
||||
@@ -1,55 +0,0 @@
|
||||
// 本地调试配置示例文件
|
||||
// 复制此文件并重命名为 local.config.js 以使用自定义本地配置
|
||||
|
||||
const localDevConfig = ({
|
||||
'460': { // 制氧设备平台
|
||||
uniacid: 460,
|
||||
domain: 'https://xcx30.5g-quickapp.com/',
|
||||
},
|
||||
'576-xcx30.5g': { // 活性石灰装备
|
||||
uniacid: 576,
|
||||
domain: 'https://xcx30.5g-quickapp.com/',
|
||||
},
|
||||
'2285': { // 数码喷墨墨水
|
||||
uniacid: 2285,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2811': { // POCT检测分析平台
|
||||
uniacid: 2811,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2812': { // IVD数商模式
|
||||
uniacid: 2812,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2724': { // 生物菌肥
|
||||
uniacid: 2724,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2505': { // 煤矿钻机
|
||||
uniacid: 2505,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2777': { // 养老服务
|
||||
uniacid: 2777,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'1': { // 开发平台
|
||||
uniacid: 1,
|
||||
domain: 'https://dev.aigc-quickapp.com',
|
||||
},
|
||||
'1-test': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'local-2': { // 测试平台
|
||||
uniacid: 2,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-2-dev': { // 本地开发测试平台
|
||||
uniacid: 2,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
})['2812']; // 选择要使用的环境配置
|
||||
|
||||
export default localDevConfig;
|
||||
404
App.vue
404
App.vue
@@ -1,380 +1,68 @@
|
||||
<script>
|
||||
import auth from 'common/js/auth.js';
|
||||
import configExternal, { themeConfig, privacyConfig } from 'common/js/config-external.js'
|
||||
import Lang from 'common/js/lang.js';
|
||||
import ThemeOverride from 'common/js/theme-override.js';
|
||||
import PrivacyManager from 'common/js/privacy.js';
|
||||
import splashState from 'common/js/splash-state.js';
|
||||
|
||||
import colorList from 'common/js/style_color.js'
|
||||
import {
|
||||
Weixin
|
||||
} from 'common/js/wx-jssdk.js';
|
||||
|
||||
// ============================================================
|
||||
// 隐私弹窗 URL/路由规则 (H5 + 微信小程序 + 鸿蒙元服务)
|
||||
//
|
||||
// 配置来源 (沿用 config-external.js 同构模式):
|
||||
// 1. 本地兜底: privacyConfig.urlRulesBase (与 langConfig/themeConfig 同构)
|
||||
// 2. 远程 patch: /api/config/init data.privacy_url_rules (由 store/init 融合)
|
||||
// 融合策略 (同上一轮):
|
||||
// - mode: 远程有值则覆盖, 否则用本地
|
||||
// - patterns: 按 patch.merge 字段决定 (replace 默认 / union / intersect)
|
||||
// 匹配: 子串匹配
|
||||
// H5: pathname + search + hash
|
||||
// 小程序/鸿蒙: 当前页面 route + query
|
||||
// 命中白名单后, 仍会走 storage / 平台级隐私检查, 已同意则不再弹
|
||||
// ============================================================
|
||||
const PRIVACY_URL_RULES_BASE = privacyConfig.urlRulesBase
|
||||
|
||||
// 启动期配置注入: 把 privacyConfig 的 bootTimeout / waitChannels / signSecret 灌进 PrivacyManager
|
||||
// 这样 PrivacyManager.show() 即便被业务侧独立调用, 也能感知启动期资源就绪状态
|
||||
// 签名密钥注入: 远程 patch 必须通过 verifyRemotePatch() 才允许 applyWithMerge
|
||||
PrivacyManager.setExternalBootTimeout(configExternal.getPrivacyBootTimeout())
|
||||
PrivacyManager.setWaitChannels(privacyConfig.waitChannels || {})
|
||||
PrivacyManager.setSignSecret(privacyConfig.signSecret || '')
|
||||
|
||||
// 登记本地 base 规则, 供后续远程 patch 融合时读取
|
||||
// 必须在 setStorageKey / loadPersistedRules 之前完成, 因为 store/init action
|
||||
// 会通过 PrivacyManager.__getBaseRules() 读取这个 base
|
||||
PrivacyManager.setBaseRules(PRIVACY_URL_RULES_BASE)
|
||||
|
||||
// uniacid 隔离的持久化 key
|
||||
// 关键: 持久化必须基于 uniacid 隔离, 否则多租户切换会读到对方租户的规则
|
||||
// 无 uniacid 时降级到 _anon (使用 localStorage 之外的隔离, 避免与任何具体租户冲突)
|
||||
const _uniacid = uni.getStorageSync('uniacid')
|
||||
const _persistKey = _uniacid
|
||||
? ('privacy_url_rules_' + _uniacid)
|
||||
: 'privacy_url_rules__anon'
|
||||
PrivacyManager.setStorageKey(_persistKey)
|
||||
|
||||
// 1) 先尝试恢复上次的"已融合规则" (跨冷启动)
|
||||
// 无持久化值 → 退化为 base
|
||||
const _persisted = PrivacyManager.loadPersistedRules()
|
||||
if (_persisted && _persisted.mode && Array.isArray(_persisted.patterns)) {
|
||||
PrivacyManager.setUrlRules(_persisted)
|
||||
} else {
|
||||
// 2) 用本地 base 兜底
|
||||
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
|
||||
}
|
||||
|
||||
// 业务侧可监听: 远程下发完成后, 会触发 'privacy:rules-updated'
|
||||
uni.$on && uni.$on('privacy:rules-updated', (info) => {
|
||||
// 只在远程下发时打印, 避免被本地 base 加载/持久化恢复时的 emit 噪音淹没
|
||||
if (info && info.source === 'remote' && console && console.log) {
|
||||
console.log('[privacy] rules updated by remote, mode=' + info.mode + ', patterns=' + info.patterns.length)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 取 onLaunch options 作为首次启动的兜底路径
|
||||
* H5: options.path/query 不一定存在, 此时用 window.location
|
||||
* 小程序/鸿蒙: options.path 是启动路径, options.query 是启动参数
|
||||
* @param {Object} launchOptions onLaunch 的 options
|
||||
* @returns {Object} urlFallback
|
||||
*/
|
||||
function _buildPrivacyUrlFallback(launchOptions) {
|
||||
const fallback = { pathname: '', search: '', hash: '', route: '', query: null }
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
fallback.pathname = window.location.pathname || ''
|
||||
fallback.search = window.location.search || ''
|
||||
fallback.hash = window.location.hash || ''
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
if (launchOptions && launchOptions.path) {
|
||||
fallback.route = launchOptions.path
|
||||
fallback.query = launchOptions.query || null
|
||||
}
|
||||
// #endif
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主题/语言启动
|
||||
// ============================================================
|
||||
export default {
|
||||
mixins: [auth],
|
||||
data() {
|
||||
return {
|
||||
// remote-only 模式下,业务可通过 await getApp().$langReady 等待语言包就绪
|
||||
$langReady: Promise.resolve({ ok: true, source: 'local' })
|
||||
};
|
||||
},
|
||||
// 同步钩子:在 page mounted 之前同步设置隐私弹窗的 skip 状态
|
||||
// 必须在 onLaunch (async) 之前跑,否则 onLaunch 第一个 await 让出事件循环时
|
||||
// privacy-popup.vue 已经挂载,会走 consumePendingShow 兜底分支把弹窗弹出来
|
||||
created() {
|
||||
if (!PrivacyManager.shouldShowByUrlRule()) {
|
||||
PrivacyManager.markSkipped()
|
||||
}
|
||||
},
|
||||
onLaunch: async function(options) {
|
||||
// ============================================================
|
||||
// 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下
|
||||
// ============================================================
|
||||
|
||||
// ---- 0) 同步预热: 立即读 storage 拿 preset,触发 require(不 await)
|
||||
// 目的: 让 loadTheme 的 require 提前进入 microtask,后续 await 几乎瞬时
|
||||
const cached = uni.getStorageSync('themeStyle');
|
||||
let presetName = themeConfig.defaultTheme;
|
||||
if (cached) {
|
||||
presetName = (typeof cached === 'object') ? cached.name : cached;
|
||||
}
|
||||
// 触发 preset require(不 await,缓存 promise 到 configExternal.loadedConfigs)
|
||||
configExternal.loadTheme(presetName);
|
||||
|
||||
// ---- 1) uniacid 立即写入(后续所有 HTTP 都要用到)
|
||||
const uniacidChanged = options.query && options.query.uniacid && String(uni.getStorageSync('uniacid') || '') !== String(options.query.uniacid);
|
||||
if (options.query && options.query.uniacid) {
|
||||
onLaunch: function(options) {
|
||||
// console.log(options.query.uniacid)
|
||||
if(options.query.uniacid){
|
||||
uni.setStorageSync('uniacid', options.query.uniacid);
|
||||
console.log(uni.getStorageSync('uniacid'))
|
||||
}
|
||||
// 关键: 即便 uniacid 未变化 (首次启动 options.query 没传 uniacid),
|
||||
// 也要尝试从 storage 拿真实 uniacid 并设置 PrivacyManager 的 persist key
|
||||
// 否则 App.vue 顶层用 _anon 占位, 后续 persistRules 写到 _anon key 下,
|
||||
// 下次冷启动 (有 uniacid) 切到 uniacid key 时读不到上次结果
|
||||
try {
|
||||
const _nowUniacid = uni.getStorageSync('uniacid')
|
||||
const _newPersistKey = _nowUniacid
|
||||
? ('privacy_url_rules_' + _nowUniacid)
|
||||
: 'privacy_url_rules__anon'
|
||||
if (_newPersistKey !== _persistKey) {
|
||||
PrivacyManager.setStorageKey(_newPersistKey)
|
||||
const _newPersisted = PrivacyManager.loadPersistedRules()
|
||||
if (_newPersisted && _newPersisted.mode && Array.isArray(_newPersisted.patterns)) {
|
||||
PrivacyManager.setUrlRules(_newPersisted)
|
||||
} else {
|
||||
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
if (uniacidChanged) {
|
||||
try { Lang.onUniacidChange(); } catch (e) {}
|
||||
try { ThemeOverride.onUniacidChange(); } catch (e) {}
|
||||
// 切租户: 切换 PrivacyManager 持久化 key 并重新应用本地 base
|
||||
// (远程 patch 由后续 /api/config/init 重新下发并融合)
|
||||
try {
|
||||
const newUniacid = String(options.query.uniacid)
|
||||
const newPersistKey = newUniacid
|
||||
? ('privacy_url_rules_' + newUniacid)
|
||||
: 'privacy_url_rules__anon'
|
||||
PrivacyManager.setStorageKey(newPersistKey)
|
||||
PrivacyManager.setBaseRules(PRIVACY_URL_RULES_BASE)
|
||||
// 切租户: 重新加载该租户上次持久化的规则
|
||||
// 无持久化值则退化为 base
|
||||
const _newPersisted = PrivacyManager.loadPersistedRules()
|
||||
if (_newPersisted && _newPersisted.mode && Array.isArray(_newPersisted.patterns)) {
|
||||
PrivacyManager.setUrlRules(_newPersisted)
|
||||
} else {
|
||||
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
|
||||
}
|
||||
} catch (e) {}
|
||||
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
|
||||
}
|
||||
|
||||
// remote-only 模式:
|
||||
// H5: main.js 已 await boot 完成, 这里秒回 (splash 也由 main.js 全权负责)
|
||||
// 小程序/鸿蒙: main.js 同步 $mount() 之前已经调用 splashState.show(),
|
||||
// 这里不需要重复调用
|
||||
const langBoot = Lang.boot();
|
||||
this.$langReady = langBoot;
|
||||
|
||||
// ---- 2) 启动期关键资源就绪 (并行 + 显式 await)
|
||||
// 隐私弹窗的诉求: 必须在 i18n + theme + remote-config 三态都就绪后才弹
|
||||
// 这里把三个通道收敛为一个 await 链, 不再用 fire-and-forget
|
||||
// 任何通道自身有超时保护 (Lang.bootTimeout=langConfig.remote.bootTimeout=5000,
|
||||
// ThemeOverride.bootTimeout=themeConfig.overrideRemote.bootTimeout=5000)
|
||||
// 总超时熔断走 configExternal.getPrivacyBootTimeout() (默认取两者最大值+1000ms)
|
||||
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大 privacyConfig.bootTimeout
|
||||
// 业务侧想"快速失败" → 调小或关闭 privacyConfig.waitChannels
|
||||
|
||||
const _i18nDone = langBoot.then((res) => {
|
||||
// 进度上报: i18n 完成 → 30%
|
||||
try { splashState.reportProgress('lang', { source: 'channel' }); } catch (e) {}
|
||||
// i18n fallback toast (原逻辑保留)
|
||||
if (res && res.status === 'fail' && res.reason && res.reason !== 'unknown') {
|
||||
if (globalThis.__i18nFallbackToastShown) return res;
|
||||
globalThis.__i18nFallbackToastShown = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
uni.showToast({
|
||||
title: `Failed to load i18n. ${res.reason} Please check your network and try again.`,
|
||||
icon: 'none',
|
||||
duration: 10000,
|
||||
});
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
return res;
|
||||
}, (e) => {
|
||||
console.warn('[i18n] boot fail', e);
|
||||
try { splashState.reportProgress('lang', { source: 'error' }); } catch (e) {}
|
||||
return { ok: false, source: 'remote-failed', status: 'fail', reason: 'exception', lang: '' };
|
||||
})
|
||||
|
||||
const _themeFirstApply = this.$store.dispatch('applyThemeWithRemote', presetName)
|
||||
.then(() => {
|
||||
try { splashState.reportProgress('theme', { source: 'channel' }); } catch (e) {}
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('[theme] pre-apply fail', e);
|
||||
try { splashState.reportProgress('theme', { source: 'error' }); } catch (e) {}
|
||||
return null
|
||||
})
|
||||
|
||||
const _themeOverride = ThemeOverride.boot()
|
||||
.then(() => {
|
||||
try { splashState.reportProgress('override', { source: 'channel' }); } catch (e) {}
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('[theme] boot fail', e);
|
||||
try { splashState.reportProgress('override', { source: 'error' }); } catch (e) {}
|
||||
return null
|
||||
})
|
||||
|
||||
// /api/config/init 走 fire-and-forget:
|
||||
// - race 不再等它, popup 可以更早弹出
|
||||
// - 远程 patch (privacy_url_rules 等) 后续异步到达时通过 applyWithMerge 触发双阶段弹窗
|
||||
// - 失败也不阻塞 boot 链路
|
||||
//
|
||||
// 进度上报单独走 (展示效果, 不依赖 _remoteConfig resolve)
|
||||
this.$store.dispatch('init').then(() => {
|
||||
try { splashState.reportProgress('config', { source: 'channel' }); } catch (e) {}
|
||||
}).catch(e => {
|
||||
console.warn('[config] init fail', e);
|
||||
try { splashState.reportProgress('config', { source: 'error' }); } catch (e) {}
|
||||
})
|
||||
const _remoteConfig = Promise.resolve(null) // 占位 promise, race 不再等 init
|
||||
|
||||
// 注入启动期等待通道 → ConfigExternal 持有
|
||||
// PrivacyManager.show() 时通过 getPrivacyReadyGuard(waitChannels) 自动拼成 readyGuard
|
||||
configExternal.setReadyWaiters({
|
||||
lang: _i18nDone,
|
||||
theme: _themeFirstApply,
|
||||
themeOverride: _themeOverride,
|
||||
remoteConfig: _remoteConfig,
|
||||
})
|
||||
|
||||
const _privacyBootTimeout = configExternal.getPrivacyBootTimeout()
|
||||
const _bootRaceResult = await Promise.race([
|
||||
Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||
new Promise(resolve => setTimeout(() => resolve('timeout'), _privacyBootTimeout))
|
||||
])
|
||||
if (_bootRaceResult === 'timeout') {
|
||||
// race 超时上报 (source: 'timeout', 不允许 percent 后退, 已有最大值为准)
|
||||
try { splashState.reportProgress('config', { percent: 80, source: 'timeout' }); } catch (e) {}
|
||||
}
|
||||
|
||||
// privacy 阶段开始上报
|
||||
try { splashState.reportProgress('privacy', { source: 'channel' }); } catch (e) {}
|
||||
|
||||
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
||||
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
||||
// 只弹一次: 同意后写 storage,后续不再弹
|
||||
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
|
||||
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
||||
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
||||
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
||||
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
||||
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
|
||||
try {
|
||||
await PrivacyManager.show({
|
||||
urlFallback: _buildPrivacyUrlFallback(options),
|
||||
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||
})
|
||||
} catch (e) { console.warn('[privacy] show fail', e); }
|
||||
|
||||
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
||||
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
||||
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
||||
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
|
||||
// #ifndef H5
|
||||
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
||||
if (_needSplash) {
|
||||
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
|
||||
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
|
||||
// 如果跟 step 2 的 apply 值相同,Vuex 会重复 commit 但页面无视觉变化
|
||||
await this.$store.dispatch('applyThemeWithRemote', presetName);
|
||||
|
||||
// ============================================================
|
||||
// 后续: 监听器 / 平台条件 / 其它 storage commit(全部同步/轻量)
|
||||
// ============================================================
|
||||
|
||||
// 监听远端翻译更新事件(管理后台改了词条后由后端推送 / 主动 reload)
|
||||
// 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh,这里
|
||||
// 只做副作用(navigation title 等),不重复 forceUpdate
|
||||
uni.$on && uni.$on('lang:updated', (info) => {
|
||||
if (info && info.lang) {
|
||||
Lang.refresh(true); // true = 显式推送 emitUpdated(虽然 loadOverrides 内部已发,这里保险起见)
|
||||
}
|
||||
});
|
||||
// 监听远端主题覆写更新(管理后台改了 token 后,本地 Vuex 重新合并并应用)
|
||||
// 关键: 走 applyThemeWithRemote action,保留原 setThemeStyle/themeColorSet 链路不动
|
||||
uni.$on && uni.$on('theme:updated', () => {
|
||||
// 重新从 storage 读 preset(可能用户在管理页切了预设)
|
||||
const cur = uni.getStorageSync('themeStyle');
|
||||
const curName = (cur && typeof cur === 'object') ? cur.name : (cur || 'default');
|
||||
this.$store.dispatch('applyThemeWithRemote', curName);
|
||||
});
|
||||
// 监听 uniacid 切换: 通知主题运行时清旧租户缓存,下次 refresh 拿到新值
|
||||
uni.$on && uni.$on('uniacid:changed', () => {
|
||||
try { ThemeOverride.onUniacidChange(); } catch (e) {}
|
||||
});
|
||||
// i18n 远端拉取失败的 toast 提示已迁移到上方 boot() 完成的 then() 中,
|
||||
// 直接根据 boot 返回值判断,不再依赖 lang:fallback 事件
|
||||
uni.hideTabBar();
|
||||
|
||||
// #ifdef MP
|
||||
if (uni.getUpdateManager) {
|
||||
const updateManager = uni.getUpdateManager();
|
||||
updateManager.onCheckForUpdate(function(res) {
|
||||
// 请求完新版本信息的回调
|
||||
});
|
||||
const updateManager = uni.getUpdateManager();
|
||||
updateManager.onCheckForUpdate(function(res) {
|
||||
// 请求完新版本信息的回调
|
||||
});
|
||||
|
||||
updateManager.onUpdateReady(function(res) {
|
||||
uni.showModal({
|
||||
title: Lang.lang('common.updateTip'),
|
||||
content: Lang.lang('common.updateReady'),
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
|
||||
updateManager.applyUpdate();
|
||||
}
|
||||
updateManager.onUpdateReady(function(res) {
|
||||
uni.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本已经准备好,是否重启应用?',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
|
||||
updateManager.applyUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateManager.onUpdateFailed(function(res) {
|
||||
// 新的版本下载失败
|
||||
});
|
||||
}
|
||||
updateManager.onUpdateFailed(function(res) {
|
||||
// 新的版本下载失败
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
if (this.$util.getDeviceInfo().platform == 'ios') {
|
||||
if (uni.getDeviceInfo().platform == 'ios') {
|
||||
uni.setStorageSync('initUrl', location.href);
|
||||
}
|
||||
// #endif
|
||||
|
||||
if (uni.onNetworkStatusChange) {
|
||||
uni.onNetworkStatusChange(function(res) {
|
||||
if (!res.isConnected) {
|
||||
uni.showModal({
|
||||
title: Lang.lang('common.networkLost'),
|
||||
content: Lang.lang('common.networkCheck'),
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
uni.onNetworkStatusChange(function(res) {
|
||||
if (!res.isConnected) {
|
||||
uni.showModal({
|
||||
title: '网络失去链接',
|
||||
content: '请检查网络链接',
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// /api/config/init 已在 step 2.5 await 过 (含 800ms 超时), 此处不再重复触发
|
||||
// 原 fire-and-forget 注释: 不阻塞后续同步 commit
|
||||
// 现在 init 已被升级为 "首次配置等待通道", 这里留空以避免重复 dispatch
|
||||
this.$store.dispatch('init');
|
||||
|
||||
// 存储到store中
|
||||
|
||||
// 主题风格
|
||||
if (uni.getStorageSync('themeStyle')) {
|
||||
this.$store.commit('setThemeStyle', colorList[uni.getStorageSync('themeStyle')]);
|
||||
}
|
||||
|
||||
// 插件是否存在
|
||||
if (uni.getStorageSync('addonIsExist')) {
|
||||
@@ -463,7 +151,6 @@
|
||||
onShow: function(options) {
|
||||
// #ifdef MP
|
||||
// 自动授权登录
|
||||
this.getAuthInfo();
|
||||
if (this.$store.state.token) {
|
||||
this.$api.sendRequest({
|
||||
url: '/api/member/info',
|
||||
@@ -473,6 +160,8 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
}else{
|
||||
this.getAuthInfo();
|
||||
}
|
||||
// #endif
|
||||
|
||||
@@ -489,8 +178,7 @@
|
||||
// #ifdef H5
|
||||
if (this.$util.isWeiXin()) {
|
||||
this.$util.getUrlCode(urlParams => {
|
||||
if (urlParams.source_member) uni.setStorageSync('source_member', urlParams
|
||||
.source_member);
|
||||
if (urlParams.source_member) uni.setStorageSync('source_member', urlParams.source_member);
|
||||
|
||||
if (urlParams.code == undefined) {
|
||||
this.$api.sendRequest({
|
||||
@@ -516,8 +204,7 @@
|
||||
let data = {};
|
||||
if (res.data.openid) data.wx_openid = res.data.openid;
|
||||
if (res.data.unionid) data.wx_unionid = res.data.unionid;
|
||||
if (res.data.userinfo) Object.assign(data, res.data
|
||||
.userinfo);
|
||||
if (res.data.userinfo) Object.assign(data, res.data.userinfo);
|
||||
this.authLogin(data);
|
||||
}
|
||||
}
|
||||
@@ -576,6 +263,7 @@
|
||||
imgUrl: this.$util.img(share_data.imgUrl)
|
||||
},
|
||||
res => {
|
||||
console.log(res);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -623,7 +311,7 @@
|
||||
@import './common/css/iconfont.css';
|
||||
@import './common/css/icondiy.css'; // 自定义图标库
|
||||
@import './common/css/icon/extend.css'; // 扩展图标库
|
||||
page {
|
||||
page{
|
||||
background: #f4f6fa;
|
||||
}
|
||||
</style>
|
||||
@@ -1,234 +1,305 @@
|
||||
.collectPopupWindow {
|
||||
position: relative;
|
||||
height: 113rpx;
|
||||
width: 510rpx;
|
||||
margin-left: calc(100% - 530rpx);
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
text {
|
||||
color: #ff4544 !important;
|
||||
font-size: 24rpx !important;
|
||||
position: absolute;
|
||||
top: 48rpx;
|
||||
right: 25rpx;
|
||||
}
|
||||
}
|
||||
.zhezhao {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: transparent;
|
||||
}
|
||||
image {
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.diy-wrap {
|
||||
/* #ifdef H5 */
|
||||
height: calc(100vh - 88rpx);
|
||||
/* #endif */
|
||||
/* #ifdef MP */
|
||||
height: 100vh;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.page-img {
|
||||
background-size: contain !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-size: 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: top center;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.bg-index {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background-size: 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
.wap-floating {
|
||||
text {
|
||||
display: block;
|
||||
font-size: 60rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.wap-floating-collect .uni-popup__mask {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.popup-box {
|
||||
width: 450rpx;
|
||||
background: #ffffff;
|
||||
border-radius: $border-radius;
|
||||
overflow: hidden;
|
||||
|
||||
.close_title {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
height: 70rpx;
|
||||
line-height: 70rpx;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.close_content {
|
||||
width: 100%;
|
||||
max-height: 500rpx;
|
||||
padding: $padding;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.close_content_box {
|
||||
width: 100%;
|
||||
max-height: 460rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.noStore-text {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.isStore-top {
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.keep-on-record {
|
||||
text-align: center;
|
||||
padding-bottom: 20rpx;
|
||||
image {
|
||||
width: 150rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.padding-bottom {
|
||||
padding-bottom: 40rpx !important;
|
||||
}
|
||||
|
||||
.choose-store {
|
||||
::v-deep .uni-popup__wrapper{
|
||||
background: none!important;
|
||||
}
|
||||
}
|
||||
|
||||
.choose-store-popup {
|
||||
padding: 30rpx;
|
||||
background-color: #fff;
|
||||
.head-wrap {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-toolbar;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
color: #202021;
|
||||
}
|
||||
.position-wrap {
|
||||
display: flex;
|
||||
color: #202021;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
.icon-dizhi {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.address {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 10rpx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
-o-text-overflow: ellipsis;
|
||||
}
|
||||
.reposition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.iconfont {
|
||||
font-size: $font-size-base;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
text {
|
||||
font-size: $font-size-tag;
|
||||
color: #fd463e;
|
||||
}
|
||||
}
|
||||
}
|
||||
.store-wrap {
|
||||
border: 1px solid $base-color;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
margin-bottom: 30rpx;
|
||||
.tag {
|
||||
background-color: #fee9ea;
|
||||
color: #fd463e;
|
||||
font-size: $font-size-activity-tag;
|
||||
display: inline-block;
|
||||
border-radius: 6rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
// #ifdef H5
|
||||
transform: scale(0.8);
|
||||
margin-left: -10rpx;
|
||||
// #endif
|
||||
}
|
||||
.store-name {
|
||||
margin: 10rpx 0;
|
||||
font-weight: bold;
|
||||
color: #202021;
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
.address {
|
||||
color: #5f6067;
|
||||
font-size: $font-size-tag;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.distance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5f6067;
|
||||
font-size: $font-size-tag;
|
||||
.iconfont {
|
||||
font-size: $font-size-base;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.other-store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5e6066;
|
||||
font-weight: bold;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.iconfont {
|
||||
margin-left: 10rpx;
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
.page-bottom {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.collectPopupWindow {
|
||||
position: relative;
|
||||
height: 113rpx;
|
||||
width: 510rpx;
|
||||
margin-left: calc(100% - 530rpx);
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
text {
|
||||
color: #ff4544 !important;
|
||||
font-size: 24rpx !important;
|
||||
position: absolute;
|
||||
top: 48rpx;
|
||||
right: 25rpx;
|
||||
}
|
||||
}
|
||||
.zhezhao {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: transparent;
|
||||
}
|
||||
image {
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.diy-wrap {
|
||||
/* #ifdef H5 */
|
||||
height: calc(100vh - 88rpx);
|
||||
/* #endif */
|
||||
/* #ifdef MP-WEIXIN */
|
||||
height: 100vh;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.page-img {
|
||||
background-size: contain !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-size: 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: top center;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.bg-index {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background-size: 100% !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
.wap-floating {
|
||||
text {
|
||||
display: block;
|
||||
font-size: 60rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.wap-floating-collect .uni-popup__mask {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.popup-box {
|
||||
width: 450rpx;
|
||||
background: #ffffff;
|
||||
border-radius: $border-radius;
|
||||
overflow: hidden;
|
||||
|
||||
.close_title {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
height: 70rpx;
|
||||
line-height: 70rpx;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.close_content {
|
||||
width: 100%;
|
||||
max-height: 500rpx;
|
||||
padding: $padding;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.close_content_box {
|
||||
width: 100%;
|
||||
max-height: 460rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.noStore-text {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.isStore-top {
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.keep-on-record {
|
||||
text-align: center;
|
||||
padding-bottom: 20rpx;
|
||||
image {
|
||||
width: 150rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.padding-bottom {
|
||||
padding-bottom: 40rpx !important;
|
||||
}
|
||||
|
||||
.choose-store {
|
||||
/deep/ .uni-popup__wrapper{
|
||||
background: none!important;
|
||||
}
|
||||
}
|
||||
|
||||
.choose-store-popup {
|
||||
padding: 30rpx;
|
||||
background-color: #fff;
|
||||
.head-wrap {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-toolbar;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
color: #202021;
|
||||
}
|
||||
.position-wrap {
|
||||
display: flex;
|
||||
color: #202021;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
.icon-dizhi {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.address {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 10rpx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
-o-text-overflow: ellipsis;
|
||||
}
|
||||
.reposition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.iconfont {
|
||||
font-size: $font-size-base;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
text {
|
||||
font-size: $font-size-tag;
|
||||
color: #fd463e;
|
||||
}
|
||||
}
|
||||
}
|
||||
.store-wrap {
|
||||
border: 1px solid $base-color;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
margin-bottom: 30rpx;
|
||||
.tag {
|
||||
background-color: #fee9ea;
|
||||
color: #fd463e;
|
||||
font-size: $font-size-activity-tag;
|
||||
display: inline-block;
|
||||
border-radius: 6rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
// #ifdef H5
|
||||
transform: scale(0.8);
|
||||
margin-left: -10rpx;
|
||||
// #endif
|
||||
}
|
||||
.store-name {
|
||||
margin: 10rpx 0;
|
||||
font-weight: bold;
|
||||
color: #202021;
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
.store-close-desc{
|
||||
color: red;
|
||||
font-size: $font-size-tag;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.address {
|
||||
color: #5f6067;
|
||||
font-size: $font-size-tag;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.distance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5f6067;
|
||||
font-size: $font-size-tag;
|
||||
.iconfont {
|
||||
font-size: $font-size-base;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.other-store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5e6066;
|
||||
font-weight: bold;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.iconfont {
|
||||
margin-left: 10rpx;
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
.page-bottom {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.chain-stores{
|
||||
.chain-store-popup{
|
||||
background-color: #fff;
|
||||
border-top-left-radius: 24rpx;
|
||||
border-top-right-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
.title{
|
||||
font-size: 36rpx;
|
||||
line-height: 104rpx;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
.body{
|
||||
padding: 20rpx 30rpx;
|
||||
background-color: #F4F4F4;
|
||||
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom)) !important;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom)) !important;
|
||||
.center{
|
||||
background-color: #fff;
|
||||
box-shadow: 4rpx 4rpx 12rpx 4rpx rgba(0,0,0,0.02);
|
||||
border-radius: 24rpx;
|
||||
padding-top: 60rpx;
|
||||
padding-bottom: 23rpx;
|
||||
.image{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.text-top{
|
||||
margin-top: 44rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
line-height: 42rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.text-bottom{
|
||||
margin-top: 20rpx;
|
||||
padding: 0 57rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
.footer{
|
||||
display: flex;
|
||||
margin-top: 20rpx;
|
||||
padding: 0 24rpx;
|
||||
button{
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
border-radius: 62rpx;
|
||||
font-size: 30rpx;
|
||||
flex:1;
|
||||
}
|
||||
button.btn-right{
|
||||
margin-left: 20rpx !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5705,9 +5705,9 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "iconj"; /* Project id 1752049 */
|
||||
src: url('https://at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff2?t=1727498649486') format('woff2'),
|
||||
url('https://at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff?t=1727498649486') format('woff'),
|
||||
url('https://at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.ttf?t=1727498649486') format('truetype');
|
||||
src: url('//at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff2?t=1727498649486') format('woff2'),
|
||||
url('//at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff?t=1727498649486') format('woff'),
|
||||
url('//at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.ttf?t=1727498649486') format('truetype');
|
||||
}
|
||||
|
||||
.iconj {
|
||||
|
||||
File diff suppressed because one or more lines are too long
1770
common/css/main.scss
1770
common/css/main.scss
File diff suppressed because one or more lines are too long
@@ -1,4 +0,0 @@
|
||||
// 修复图片垂直对齐问题,解决两张图片上下有空白缝隙问题
|
||||
::v-deep ._img {
|
||||
vertical-align: top;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,9 @@
|
||||
import Config from './config.js'
|
||||
import http from './http.js'
|
||||
import store from '@/store/index.js'
|
||||
import lang from './lang.js'
|
||||
|
||||
let currentConversationId = null;
|
||||
export const CONVERSATION_KEY = 'ai_conversation_id';
|
||||
|
||||
/**
|
||||
* 获取域名配置参数(避免重复代码)
|
||||
*/
|
||||
function getDomainParams() {
|
||||
return {
|
||||
imgDomain: Config.imgDomain,
|
||||
h5Domain: Config.h5Domain,
|
||||
baseUrl: Config.baseUrl
|
||||
};
|
||||
}
|
||||
const CONVERSATION_KEY = 'ai_conversation_id';
|
||||
|
||||
// 初始化时从本地读取会话ID
|
||||
try {
|
||||
@@ -27,234 +15,32 @@ try {
|
||||
console.warn('读取会话ID失败:', e);
|
||||
}
|
||||
|
||||
const GUEST_ID_KEY = 'ai_guest_user_id';
|
||||
|
||||
/**
|
||||
* 获取用户ID:已登录返回 memberId,未登录生成访客唯一标识并持久化
|
||||
*/
|
||||
function getUserId() {
|
||||
const memberId = store.state.memberInfo?.id;
|
||||
if (memberId) return memberId;
|
||||
|
||||
let guestId;
|
||||
try {
|
||||
guestId = uni.getStorageSync(GUEST_ID_KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (!guestId) {
|
||||
guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
|
||||
try {
|
||||
uni.setStorageSync(GUEST_ID_KEY, guestId);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return guestId;
|
||||
}
|
||||
|
||||
// #ifdef MP
|
||||
// ================================
|
||||
// WebSocket 长连接管理(微信小程序)
|
||||
// ================================
|
||||
let wsSocketTask = null;
|
||||
let wsAuthenticated = false;
|
||||
let wsConnected = false;
|
||||
let wsConnectionPromise = null;
|
||||
|
||||
function getWsDomain() {
|
||||
var match = Config.baseUrl.match(/^(?:https?:\/\/|wss?:\/\/)([^\/\s:?#]+(?::\d+)?)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function resetWsState() {
|
||||
wsSocketTask = null;
|
||||
wsAuthenticated = false;
|
||||
wsConnected = false;
|
||||
wsConnectionPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 WebSocket 已连接并认证,返回一个 Promise
|
||||
* 连接建立后保持不断开,后续可复用
|
||||
*/
|
||||
function ensureWebSocketConnected() {
|
||||
if (wsConnected && wsAuthenticated) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (wsConnectionPromise) {
|
||||
return wsConnectionPromise;
|
||||
}
|
||||
|
||||
wsConnectionPromise = new Promise(function(resolve, reject) {
|
||||
var domain = getWsDomain();
|
||||
if (!domain) {
|
||||
wsConnectionPromise = null;
|
||||
reject(new Error(lang.lang('aiService.wsDomainInvalid')));
|
||||
return;
|
||||
}
|
||||
|
||||
var wssUrl = 'wss://' + domain + '/ws/aikefu';
|
||||
wsConnected = false;
|
||||
wsAuthenticated = false;
|
||||
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
|
||||
|
||||
// 获取认证参数(独立函数,不使用 this)
|
||||
var _uniacid = uni.getStorageSync('uniacid') || Config.uniacid;
|
||||
var _token = store.state.token || 'test_token';
|
||||
var _userId = getUserId();
|
||||
|
||||
wsSocketTask.onOpen(function() {
|
||||
wsConnected = true;
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'auth',
|
||||
uniacid: _uniacid,
|
||||
token: _token,
|
||||
user_id: _userId
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
wsSocketTask.onMessage(function(res) {
|
||||
try {
|
||||
var data = JSON.parse(res.data);
|
||||
|
||||
// Auth 响应
|
||||
if (data.type === 'auth_success') {
|
||||
wsAuthenticated = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (data.type === 'auth_failed') {
|
||||
wsAuthenticated = false;
|
||||
wsConnectionPromise = null;
|
||||
reject(new Error(lang.lang('aiService.wsAuthFailed')));
|
||||
return;
|
||||
}
|
||||
|
||||
// 以下为聊天消息响应,只在有活跃消息时处理
|
||||
if (!wsMessageActive) return;
|
||||
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.content || data.text || '';
|
||||
// 兼容嵌套 data.data.content 格式
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
}
|
||||
wsMessageContent += text;
|
||||
if (wsMessageOnChunk) wsMessageOnChunk(text);
|
||||
} else if (data.event === 'message_end' || data.event === 'done') {
|
||||
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
|
||||
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
|
||||
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
|
||||
// 保存会话ID(独立函数,不使用 this)
|
||||
if (conversationId) {
|
||||
if (conversationId !== currentConversationId) {
|
||||
currentConversationId = conversationId;
|
||||
try {
|
||||
uni.setStorageSync(CONVERSATION_KEY, conversationId);
|
||||
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
|
||||
} catch (e) {
|
||||
console.error('保存会话ID失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
var content = wsMessageContent;
|
||||
var convId = conversationId;
|
||||
// 重置消息状态,但不断开连接
|
||||
wsMessageActive = false;
|
||||
wsMessageContent = '';
|
||||
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId });
|
||||
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId });
|
||||
} else {
|
||||
// 兜底日志:打印所有未识别的消息类型
|
||||
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
|
||||
}
|
||||
});
|
||||
|
||||
wsSocketTask.onError(function(err) {
|
||||
console.error('WebSocket 连接失败:', err);
|
||||
var prevReject = wsMessageReject;
|
||||
resetWsState();
|
||||
resetWsMessageState();
|
||||
if (prevReject) prevReject(new Error(lang.lang('aiService.wsConnectionFailed')));
|
||||
reject(new Error(lang.lang('aiService.wsConnectionFailed')));
|
||||
});
|
||||
|
||||
wsSocketTask.onClose(function() {
|
||||
console.warn('WebSocket 连接已关闭');
|
||||
var prevReject = wsMessageReject;
|
||||
resetWsState();
|
||||
resetWsMessageState();
|
||||
if (prevReject) prevReject(new Error(lang.lang('aiService.wsConnectionClosed')));
|
||||
});
|
||||
});
|
||||
|
||||
return wsConnectionPromise;
|
||||
}
|
||||
|
||||
// 当前消息的回调状态(只支持串行)
|
||||
var wsMessageResolve = null;
|
||||
var wsMessageReject = null;
|
||||
var wsMessageOnChunk = null;
|
||||
var wsMessageOnComplete = null;
|
||||
var wsMessageContent = '';
|
||||
var wsMessageActive = false;
|
||||
|
||||
function resetWsMessageState() {
|
||||
wsMessageResolve = null;
|
||||
wsMessageReject = null;
|
||||
wsMessageOnChunk = null;
|
||||
wsMessageOnComplete = null;
|
||||
wsMessageContent = '';
|
||||
wsMessageActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开 WebSocket 长连接
|
||||
*/
|
||||
function disconnectWebSocket() {
|
||||
if (wsSocketTask) {
|
||||
try { wsSocketTask.close(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
resetWsState();
|
||||
resetWsMessageState();
|
||||
}
|
||||
// #endif
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 获取uniacid
|
||||
* @returns {number} uniacid
|
||||
*/
|
||||
getUniacid() {
|
||||
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送普通消息(blocking 模式)
|
||||
*/
|
||||
async sendMessage(message, options = {}) {
|
||||
try {
|
||||
const aiConfig = store.getters.globalAIKefuConfig;
|
||||
const params = {
|
||||
url: '/api/kefu/chat',
|
||||
data: {
|
||||
user_id: getUserId(),
|
||||
user_id: store.state.memberInfo?.id || 'anonymous',
|
||||
stream: false,
|
||||
inputs: {},
|
||||
query: message,
|
||||
response_mode: 'blocking', // 强制 blocking
|
||||
user: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
...getDomainParams()
|
||||
user: store.state.memberInfo?.id || 'anonymous',
|
||||
conversation_id: this.getConversationId() || ''
|
||||
},
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Sending blocking request to:', params.url);
|
||||
console.log('Request data:', params.data);
|
||||
|
||||
const response = await http.sendRequest({
|
||||
...params,
|
||||
async: false
|
||||
@@ -266,55 +52,63 @@ export default {
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[AI Service] AiChat API请求失败,详细错误:', error);
|
||||
console.error('[AI Service] 错误堆栈:', error.stack);
|
||||
// 打印更多信息帮助调试
|
||||
console.error('[AI Service] store 状态:', {
|
||||
memberInfo: store.state.memberInfo,
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token
|
||||
});
|
||||
throw new Error(lang.lang('aiService.serviceUnavailable'));
|
||||
console.error('Dify API请求失败:', error);
|
||||
throw new Error('AI服务暂时不可用,请稍后重试');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 流式消息入口(自动适配平台)
|
||||
* 微信小程序:复用长连接 WebSocket
|
||||
*/
|
||||
async sendStreamMessage(message, onChunk, onComplete) {
|
||||
// #ifdef MP
|
||||
// 先确保连接已就绪
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序:降级为普通请求 + 前端打字模拟
|
||||
try {
|
||||
await ensureWebSocketConnected();
|
||||
} catch (e) {
|
||||
if (onComplete) onComplete({ error: e.message });
|
||||
throw e;
|
||||
}
|
||||
const result = await this.sendMessage(message);
|
||||
const content = result.content || '';
|
||||
const conversationId = result.conversationId || '';
|
||||
|
||||
// 设置当前消息回调
|
||||
wsMessageContent = '';
|
||||
wsMessageActive = true;
|
||||
wsMessageOnChunk = onChunk || null;
|
||||
wsMessageOnComplete = onComplete || null;
|
||||
// 保存会话ID(确保连续对话)
|
||||
if (conversationId) {
|
||||
this.setConversationId(conversationId);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wsMessageResolve = resolve;
|
||||
wsMessageReject = reject;
|
||||
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'chat',
|
||||
uniacid: this.getUniacid(),
|
||||
query: message,
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
...getDomainParams()
|
||||
})
|
||||
// 模拟打字效果
|
||||
let index = 0;
|
||||
const chunkSize = 2; // 每次显示2个字符
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (index < content.length) {
|
||||
const chunk = content.substring(index, index + chunkSize);
|
||||
index += chunkSize;
|
||||
if (onChunk) onChunk(chunk);
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
if (onComplete) {
|
||||
onComplete({
|
||||
content: content,
|
||||
conversation_id: conversationId
|
||||
});
|
||||
}
|
||||
resolve({ content, conversation_id: conversationId });
|
||||
}
|
||||
}, 80); // 打字速度:80ms/次
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('小程序流式消息降级失败:', error);
|
||||
if (onComplete) {
|
||||
onComplete({ error: error.message || '发送失败' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// H5:使用真实流式(EventSource / Fetch)
|
||||
return this.sendHttpStream(message, onChunk, onComplete);
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP 流式请求(仅 H5 使用)
|
||||
*/
|
||||
@@ -329,19 +123,20 @@ export default {
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || 'test_token',
|
||||
uniacid: store.state.uniacid || '1',
|
||||
inputs: {},
|
||||
query: message,
|
||||
response_mode: 'streaming',
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
...getDomainParams()
|
||||
user_id: store.state.memberInfo?.id || 'anonymous',
|
||||
conversation_id: this.getConversationId() || ''
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(lang.lang('aiService.invalidResponse'));
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('响应体不可用');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -350,75 +145,50 @@ export default {
|
||||
let content = '';
|
||||
let conversationId = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 按行分割,保留不完整的最后一行
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 未完成的行留到下次
|
||||
|
||||
function processBuffer(buf, callback) {
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = trimmed.slice(5).trim();
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
const jsonStr = trimmed.slice(5).trim();
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.text || '';
|
||||
// 兼容嵌套 data.data 格式
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
}
|
||||
const text = data.answer || data.text || '';
|
||||
content += text;
|
||||
if (onChunk) onChunk(text);
|
||||
callback(text);
|
||||
}
|
||||
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
|
||||
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
if (cid) {
|
||||
conversationId = cid;
|
||||
if (data.conversation_id) {
|
||||
conversationId = data.conversation_id;
|
||||
}
|
||||
if (data.event === 'message_end' || data.event === 'done') {
|
||||
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
|
||||
if (data.event === 'message_end') {
|
||||
// 可选:提前完成
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析流数据失败:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析失败:', e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// 处理最后残留的 buffer(如果有)
|
||||
if (buffer.trim().startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = buffer.trim().slice(5);
|
||||
if (jsonStr) {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.text || '';
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
}
|
||||
content += text;
|
||||
if (onChunk) onChunk(text);
|
||||
}
|
||||
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
if (cid) {
|
||||
conversationId = cid;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('最后 buffer 解析失败:', e);
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = processBuffer(buffer, (chunk) => {
|
||||
if (onChunk) onChunk(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length);
|
||||
if (onComplete) {
|
||||
onComplete({ content, conversation_id: conversationId });
|
||||
onComplete({
|
||||
content,
|
||||
conversation_id: conversationId
|
||||
});
|
||||
}
|
||||
return { content, conversation_id: conversationId };
|
||||
} catch (error) {
|
||||
@@ -426,6 +196,11 @@ export default {
|
||||
throw error;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 理论上不会执行到这里,但防止 fallback
|
||||
return this.sendStreamMessage(message, onChunk, onComplete);
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -440,7 +215,7 @@ export default {
|
||||
messageId: response.data?.message_id
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
|
||||
throw new Error(response.message || 'AI服务返回错误');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -484,11 +259,9 @@ export default {
|
||||
const params = {
|
||||
url: '/api/kefu/createConversation',
|
||||
data: {
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || 'test_token',
|
||||
user_id: getUserId(),
|
||||
member_id: getUserId(),
|
||||
...getDomainParams()
|
||||
uniacid: store.state.uniacid,
|
||||
user_id: store.state.memberInfo?.id || 'anonymous',
|
||||
member_id: store.state.memberInfo?.id || '',
|
||||
},
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -515,46 +288,16 @@ export default {
|
||||
.filter(item => item === 'healthy').length > 0;
|
||||
return {
|
||||
available,
|
||||
reason: available ? lang.lang('aiService.statusNormal') : lang.lang('aiService.statusAbnormal')
|
||||
reason: available ? '服务正常' : '服务异常'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
reason: lang.lang('aiService.statusCheckFailed')
|
||||
reason: '服务检查失败'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取服务配置信息
|
||||
*/
|
||||
async getServiceConfig() {
|
||||
try {
|
||||
const params = {
|
||||
url: '/api/kefu/info',
|
||||
data: {
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || 'test_token',
|
||||
user_id: getUserId(),
|
||||
member_id: getUserId(),
|
||||
locale: lang.getCurrentLocale()
|
||||
},
|
||||
header: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
const response = await http.sendRequest({
|
||||
...params,
|
||||
async: false,
|
||||
});
|
||||
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AI Service] getServiceConfig error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除会话历史(调用后端接口)
|
||||
*/
|
||||
@@ -562,10 +305,7 @@ export default {
|
||||
try {
|
||||
const response = await http.sendRequest({
|
||||
url: '/api/kefu/clear-conversation',
|
||||
data: {
|
||||
conversation_id: conversationId,
|
||||
...getDomainParams()
|
||||
},
|
||||
data: { conversation_id: conversationId },
|
||||
async: false
|
||||
});
|
||||
return response.success;
|
||||
@@ -581,18 +321,16 @@ export default {
|
||||
async getConversationHistory(params = {}) {
|
||||
try {
|
||||
if (!params.conversation_id) {
|
||||
throw new Error(lang.lang('aiService.conversationIdRequired'));
|
||||
throw new Error('会话ID(conversation_id)是必填参数');
|
||||
}
|
||||
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
|
||||
const requestData = {
|
||||
uniacid: params.uniacid || this.getUniacid(),
|
||||
uniacid: params.uniacid || store.state.uniacid || '1',
|
||||
conversation_id: params.conversation_id,
|
||||
user_id: params.user_id || getUserId(),
|
||||
user_id: params.user_id || store.state.memberInfo?.id || 'anonymous',
|
||||
limit: params.limit || 20,
|
||||
offset: params.offset || 0,
|
||||
member_id: params.member_id || getUserId(),
|
||||
token: params.token || '',
|
||||
...getDomainParams()
|
||||
member_id: params.member_id || store.state.memberInfo?.id || '',
|
||||
token: params.token || ''
|
||||
};
|
||||
const response = await http.sendRequest({
|
||||
url: '/api/kefu/getHistory',
|
||||
@@ -611,11 +349,11 @@ export default {
|
||||
page_info: response.data?.page_info || { limit: 20, offset: 0 }
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.message || lang.lang('aiService.getHistoryFailed'));
|
||||
throw new Error(response.message || '获取会话历史失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取会话历史失败:', error);
|
||||
throw new Error(error.message || lang.lang('aiService.getHistoryError'));
|
||||
throw new Error(error.message || '获取会话历史时发生错误,请稍后重试');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -631,17 +369,13 @@ export default {
|
||||
if (window.currentEventSource) {
|
||||
window.currentEventSource.close();
|
||||
}
|
||||
const paramsObj = {
|
||||
uniacid: this.getUniacid(),
|
||||
user_id: getUserId(),
|
||||
member_id: getUserId(),
|
||||
token: store.state.token || 'test_token',
|
||||
const params = new URLSearchParams({
|
||||
uniacid: store.state.uniacid || '1',
|
||||
user_id: store.state.memberInfo?.id || '123456',
|
||||
query: message,
|
||||
conversation_id: conversationId || '',
|
||||
stream: 'true',
|
||||
...getDomainParams()
|
||||
};
|
||||
const params = new URLSearchParams(paramsObj);
|
||||
stream: 'true'
|
||||
});
|
||||
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`;
|
||||
try {
|
||||
const eventSource = new EventSource(url);
|
||||
@@ -655,9 +389,9 @@ export default {
|
||||
if (onMessage) onMessage(data.answer || '');
|
||||
}
|
||||
if (data.event === 'message_end') {
|
||||
if (onComplete) onComplete({
|
||||
if (onComplete) onComplete({
|
||||
conversation_id: data.conversation_id,
|
||||
message: aiMessage
|
||||
message: aiMessage
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -666,7 +400,7 @@ export default {
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('EventSource错误:', error);
|
||||
if (onError) onError({ error: lang.lang('aiService.connectionFailed') });
|
||||
if (onError) onError({ error: '连接失败' });
|
||||
window.currentEventSource = null;
|
||||
};
|
||||
return eventSource;
|
||||
@@ -676,7 +410,7 @@ export default {
|
||||
return null;
|
||||
}
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
// #ifdef MP-WEIXIN
|
||||
console.warn('chatWithEventSource 不支持微信小程序');
|
||||
return null;
|
||||
// #endif
|
||||
@@ -692,23 +426,9 @@ export default {
|
||||
onComplete?.({ message: res.content, conversation_id: res.conversation_id });
|
||||
});
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
// #ifdef MP-WEIXIN
|
||||
console.warn('chatWithFetchStream 不支持微信小程序');
|
||||
return null;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// #ifdef MP
|
||||
/**
|
||||
* [微信小程序] 断开 WebSocket 长连接(页面离开时调用)
|
||||
*/
|
||||
disconnectWebSocket() {
|
||||
disconnectWebSocket();
|
||||
},
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
disconnectWebSocket() {
|
||||
// H5 无 WebSocket 长连接,无需操作
|
||||
},
|
||||
// #endif
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,113 +1,100 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
authInfo: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 获取用户登录凭证code
|
||||
*/
|
||||
getCode(callback, fallbackFunc) {
|
||||
console.log(`-----> auth.js getCode`)
|
||||
// 微信小程序
|
||||
// #ifdef MP-WEIXIN
|
||||
console.log(`微信小程序登录`)
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
timeout: 3000,
|
||||
success: res => {
|
||||
if (res.code) {
|
||||
this.$api.sendRequest({
|
||||
url: '/weapp/api/weapp/authcodetoopenid',
|
||||
data: {
|
||||
code: res.code
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data.openid) this.authInfo.weapp_openid = res.data
|
||||
.openid;
|
||||
if (res.data.unionid) this.authInfo.wx_unionid = res.data
|
||||
.unionid;
|
||||
typeof callback == 'function' && callback(this.authInfo);
|
||||
} else {
|
||||
fallbackFunc && fallbackFunc(res);
|
||||
this.$util.showToast({
|
||||
title: res.message ? res.message : this.$lang('auth.miniProgramConfigError')
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
fallbackFunc && fallbackFunc(res);
|
||||
}
|
||||
},
|
||||
fail: (res) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
let scene = wx.getLaunchOptionsSync().scene;
|
||||
if ([1154, 1155].indexOf(scene) == -1) {
|
||||
this.$util.showToast({
|
||||
title: res.errMsg
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
fallbackFunc && fallbackFunc(res);
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
uni.login({
|
||||
timeout: 3000,
|
||||
success: res => {
|
||||
if (res.code) {
|
||||
this.$api.sendRequest({
|
||||
url: '/aliapp/api/aliapp/authcodetouserid',
|
||||
data: {
|
||||
code: res.code
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data.user_id) this.authInfo.ali_openid = res.data
|
||||
.user_id;
|
||||
typeof callback == 'function' && callback(this.authInfo);
|
||||
} else {
|
||||
fallbackFunc && fallbackFunc(res);
|
||||
this.$util.showToast({
|
||||
title: res.message ? res.message : this.$lang('auth.miniProgramConfigError')
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
this.$util.showToast({
|
||||
title: res.errMsg
|
||||
});
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
if (this.$util.isWeiXin()) {
|
||||
this.$api.sendRequest({
|
||||
url: '/wechat/api/wechat/authcode',
|
||||
data: {
|
||||
redirect_url: location.href,
|
||||
scopes: 'snsapi_userinfo'
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
location.href = res.data;
|
||||
}
|
||||
},
|
||||
fail: err => {
|
||||
fallbackFunc && fallbackFunc(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
authInfo: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 获取用户登录凭证code
|
||||
*/
|
||||
getCode(callback) {
|
||||
// 微信小程序
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
timeout: 3000,
|
||||
success: res => {
|
||||
if (res.code) {
|
||||
this.$api.sendRequest({
|
||||
url: '/weapp/api/weapp/authcodetoopenid',
|
||||
data: {
|
||||
code: res.code
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data.openid) this.authInfo.weapp_openid = res.data.openid;
|
||||
if (res.data.unionid) this.authInfo.wx_unionid = res.data.unionid;
|
||||
typeof callback == 'function' && callback(this.authInfo);
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
title: res.message ? res.message : '小程序配置错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (res) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
let scene = wx.getLaunchOptionsSync().scene;
|
||||
if ([1154, 1155].indexOf(scene) == -1) {
|
||||
this.$util.showToast({
|
||||
title: res.errMsg
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
uni.login({
|
||||
timeout: 3000,
|
||||
success: res => {
|
||||
if (res.code) {
|
||||
this.$api.sendRequest({
|
||||
url: '/aliapp/api/aliapp/authcodetouserid',
|
||||
data: {
|
||||
code: res.code
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
if (res.data.user_id) this.authInfo.ali_openid = res.data.user_id;
|
||||
typeof callback == 'function' && callback(this.authInfo);
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
title: res.message ? res.message : '小程序配置错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (res) => {
|
||||
this.$util.showToast({
|
||||
title: res.errMsg
|
||||
});
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
if (this.$util.isWeiXin()) {
|
||||
this.$api.sendRequest({
|
||||
url: '/wechat/api/wechat/authcode',
|
||||
data: {
|
||||
redirect_url: location.href,
|
||||
scopes: 'snsapi_userinfo'
|
||||
},
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
location.href = res.data;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
// 外置配置管理
|
||||
// 用于管理需要外置的配置项,如语言包、主题配置等
|
||||
|
||||
// 语言包配置
|
||||
export const langConfig = {
|
||||
// 语言列表 — 与后端 Lang.php::overrides() 白名单对齐
|
||||
// 增删需要后端同步改 LangOverrideModel::normalizeAppType 旁的 $allow_lang
|
||||
langList: ['zh-cn', 'en-us'],
|
||||
// 默认语言
|
||||
defaultLang: 'zh-cn',
|
||||
// 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载)
|
||||
loadMode: 'lazy',
|
||||
// 数据源开关
|
||||
// 数据源开关
|
||||
// local : 纯本地包,忽略 remote
|
||||
// remote : 本地兜底 + 后端 KV 覆写(首屏强依赖,见 bootTimeout)
|
||||
// hybrid : 同 remote,后端不可用时用本地
|
||||
// remote-only : 完全依赖后端 /api/lang/bundle,本地 lang/ 目录可删除
|
||||
source: 'remote-only',
|
||||
// 远端覆写相关
|
||||
remote: {
|
||||
overridesUrl: '/api/lang/overrides',
|
||||
// bundle 接口(remote-only 模式下使用)
|
||||
bundleUrl: '/api/lang/bundle',
|
||||
// diff 接口(预留)
|
||||
diffUrl: '/api/lang/diff',
|
||||
// 首屏强依赖远端翻译(多租户文案强隔离),阻塞启动
|
||||
// 但必须有上限避免白屏
|
||||
// 2026-07-02: 从 2000 改为 5000 — 慢启动场景下,给首屏渲染更多时间
|
||||
bootTimeout: 5000,
|
||||
// 单次请求超时(ms)
|
||||
// 2026-07-02: 从 4000 改为 60000 — 远端首次冷启动可能慢(CDN 边缘冷缓存/跨网段回源/MySQL JOIN)
|
||||
// 配合 loadBundleTree 的"迟到数据补救"机制,即便超时也能拿到远端最终返回的数据
|
||||
requestTimeout: 60000,
|
||||
// 缓存有效期(秒),与后端 Cache::set 600s 对齐;过期后后台刷新
|
||||
cacheTtl: 600,
|
||||
// 本地缓存 key 前缀: <prefix><uniacid>_<appType>_<lang>
|
||||
// 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致
|
||||
storagePrefix: 'LANG_OVR_',
|
||||
// 切租户/uniacid 时强制重拉
|
||||
refreshOnUniacidChange: true,
|
||||
// 签名校验密钥,留空不校验(需与后端 LANG_SIGN_SECRET 一致)
|
||||
signSecret: '',
|
||||
}
|
||||
};
|
||||
|
||||
// 主题配置
|
||||
export const themeConfig = {
|
||||
// 主题列表 — 与 style_color.js 顶层的 preset 名严格一致(8 个)
|
||||
// 关键: 这里列出的名字必须能在 style_color.js (或后端 style_color.php 镜像) 中找到对应 preset
|
||||
// 历史上曾包含 'red' 但 style_color.js 从未定义 'red' preset, 切到 'red' 主题时
|
||||
// loadTheme() 会返回 undefined → 走 stub default 兜底样式, 远端也无法补齐
|
||||
// 2026-07-07 移除 'red' 以保持一致性
|
||||
themeList: ['default', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'],
|
||||
// 默认主题
|
||||
defaultTheme: 'default',
|
||||
// 远端 token 覆写相关(与 langConfig 同构)
|
||||
// source : 'local' | 'remote' | 'hybrid' | 'remote-only'
|
||||
// remote.* : 首屏拉取 + 缓存 + 三态机 + 多租户多端隔离
|
||||
overrideRemote: {
|
||||
overridesUrl: '/api/theme/overrides',
|
||||
// 全量 preset 拉取(remote-only 模式下使用)
|
||||
// 返回 8 个 preset 完整 token,替代本地 style_color.js
|
||||
presetsUrl: '/api/theme/presets',
|
||||
bootTimeout: 5000,
|
||||
requestTimeout: 60000,
|
||||
// 远端全量 preset 缓存 key 前缀
|
||||
storagePresetsPrefix: 'THEME_PRESETS_',
|
||||
storagePrefix: 'THEME_OVR_',
|
||||
refreshOnUniacidChange: true,
|
||||
// source: 'remote-only' 时,webpack 会用 NormalModuleReplacementPlugin
|
||||
// 把 style_color.js 替换为 empty-style-stub.js(只含 emergency fallback)
|
||||
// 节省主包 ~75KB(完整 8 preset × ~50 token = 80KB → stub 7KB)
|
||||
//
|
||||
// 与 lang/ 目录的 remote-only 模式完全同构:
|
||||
// - 构建期: webpack 替换为 stub
|
||||
// - 运行时: ThemeOverride.loadAllPresets() 异步拉 /api/theme/presets
|
||||
// - 首屏: 走 stub 的 default preset,不白屏
|
||||
// - 切换: 切到非 default preset 时,等远端拉完再应用(由 applyThemeWithRemote
|
||||
// 订阅 theme:presets-updated 事件驱动)
|
||||
source: 'remote-only',
|
||||
}
|
||||
};
|
||||
|
||||
// API配置
|
||||
export const apiConfig = {
|
||||
// API请求超时时间
|
||||
timeout: 30000,
|
||||
// 是否开启重试
|
||||
retry: true,
|
||||
// 重试次数
|
||||
retryCount: 3,
|
||||
// 重试间隔时间(毫秒)
|
||||
retryDelay: 1000
|
||||
};
|
||||
|
||||
// 隐私弹窗配置 (与 langConfig/themeConfig 同构)
|
||||
// 本地兜底 + 远程 patch (来自 /api/config/init data.privacy_url_rules) 融合
|
||||
// source: 'local' - 仅本地兜底, 忽略远程 patch
|
||||
// 'remote' - 本地兜底 + 远程 patch 覆盖 (默认)
|
||||
// 'hybrid' - 同 remote, 远程失败时退回本地
|
||||
// 'remote-only' - 完全依赖远程 patch, 本地兜底为空
|
||||
export const privacyConfig = {
|
||||
// 本地兜底规则 (mode: 'all'|'only'|'skip', patterns: string[])
|
||||
// 远程 patch 由 store/index.js 的 init action 通过 PrivacyManager.applyWithMerge() 融合
|
||||
// patterns 写法:
|
||||
// - 子串匹配 + 结尾匹配 (双通道), 业务侧可以写完整路径或相对路径
|
||||
// - subPackage 路由 (如 /pages_tool/member/index) 用短路径 'member/index' 即可命中
|
||||
urlRulesBase: {
|
||||
// 场景 2: 只有首页、商品详情、个人中心才弹, 其他页面都不弹
|
||||
mode: 'only',
|
||||
patterns: [
|
||||
// 首页 (含 subPackage 重定向后也是 pages/index/index)
|
||||
'pages/index/index',
|
||||
// 商品详情 (subPackage 内可能为 pages_goods/goods/detail/detail, 这里用末尾匹配)
|
||||
'goods/detail/detail',
|
||||
// 个人中心 (subPackage 内通常为 pages_tool/member/index)
|
||||
'member/index',
|
||||
],
|
||||
},
|
||||
// dataSource: 与 langConfig.source 同构
|
||||
// 'local' 纯本地, 忽略 /api/config/init 的 privacy_url_rules
|
||||
// 'remote' 本地 + 远程 patch (远程失败时仍用本地)
|
||||
// 'hybrid' 同 remote, 语义别名
|
||||
// 'remote-only' 必须有远程 patch, 本地空 patterns → 一律不弹
|
||||
source: 'remote',
|
||||
// 启动期等待通道 (弹窗必须等这些 promise 才弹)
|
||||
// waitLang: Lang.boot() - i18n 数据就绪
|
||||
// waitTheme: applyThemeWithRemote() - 本地 preset 应用
|
||||
// waitThemeOverride: ThemeOverride.boot() - brand/light/dark 三桶 token
|
||||
// waitRemoteConfig: /api/config/init (含 privacy_url_rules 远程 patch)
|
||||
waitChannels: {
|
||||
lang: true,
|
||||
theme: true,
|
||||
themeOverride: true,
|
||||
remoteConfig: true,
|
||||
},
|
||||
// 总超时熔断 (ms): 所有等待通道超过这个时间强制放行, 不阻塞弹窗
|
||||
// 取 langConfig.remote.bootTimeout (5000) 和 themeConfig.overrideRemote.bootTimeout (5000) 的最大值
|
||||
// + 1000ms 余量 → 6000ms. 与原硬编码值一致, 但可通过修改此字段调整.
|
||||
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大; 想"快速失败" → 调小.
|
||||
bootTimeout: 6000,
|
||||
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||||
// 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||||
// secret='' → 跳过校验, 接受任意 patch (向后兼容老后端)
|
||||
// secret='xxx' → 校验 patch.sign, 不匹配则拒绝
|
||||
// 留空默认不校验. 前端实现是 btoa 弱校验, 仅防简单篡改, 强校验建议用 crypto-js.
|
||||
signSecret: '',
|
||||
// 单次 /api/config/init 请求超时 (ms)
|
||||
// 复用 apiConfig.timeout, 不单独配置
|
||||
// remote: {
|
||||
// // 远程 patch 字段名 (后端 /api/config/init 返回 data.privacy_url_rules)
|
||||
// fieldPath: 'privacy_url_rules',
|
||||
// // 切租户/uniacid 时强制重拉 (远端 patch 重新下发)
|
||||
// refreshOnUniacidChange: true,
|
||||
// // 本地缓存 key 前缀: <prefix><uniacid>
|
||||
// storagePrefix: 'privacy_url_rules_',
|
||||
// },
|
||||
};
|
||||
|
||||
// 配置外置管理类
|
||||
class ConfigExternal {
|
||||
constructor() {
|
||||
this.loadedConfigs = {};
|
||||
this.loadPromises = {};
|
||||
// 启动期各通道的 await promise (由 App.vue onLaunch 注入)
|
||||
// 隐私弹窗 show() 时通过 PrivacyManager.getReadyWaiters() 拿到 → 串成 readyGuard
|
||||
this.readyWaiters = {
|
||||
lang: null,
|
||||
theme: null,
|
||||
themeOverride: null,
|
||||
remoteConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置启动期各通道的 await promise
|
||||
* App.vue onLaunch 在 step 2 注入, PrivacyManager.show() 时读取
|
||||
* @param {Object} waiters { lang?, theme?, themeOverride?, remoteConfig? }
|
||||
*/
|
||||
setReadyWaiters(waiters) {
|
||||
if (!waiters || typeof waiters !== 'object') return
|
||||
for (const k of Object.keys(this.readyWaiters)) {
|
||||
if (waiters[k] && typeof waiters[k].then === 'function') {
|
||||
this.readyWaiters[k] = waiters[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隐私弹窗 readyGuard (合并所有启用的等待通道)
|
||||
* 任意通道 disabled → 不等
|
||||
* 所有通道都 null → 返回 Promise.resolve() (不退化)
|
||||
* @param {Object} channels { lang?, theme?, themeOverride?, remoteConfig? }
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getPrivacyReadyGuard(channels) {
|
||||
const cfg = (typeof channels === 'object' && channels) || (privacyConfig.waitChannels || {})
|
||||
const promises = []
|
||||
for (const k of ['lang', 'theme', 'themeOverride', 'remoteConfig']) {
|
||||
if (cfg[k] && this.readyWaiters[k]) {
|
||||
promises.push(this.readyWaiters[k])
|
||||
}
|
||||
}
|
||||
if (!promises.length) return Promise.resolve()
|
||||
return Promise.all(promises).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐私弹窗总超时熔断 (ms)
|
||||
* 默认取 langConfig.remote.bootTimeout 与 themeConfig.overrideRemote.bootTimeout 的最大值 + 1000ms 余量
|
||||
* 业务侧可通过 privacyConfig.bootTimeout 覆盖
|
||||
*/
|
||||
getPrivacyBootTimeout() {
|
||||
if (privacyConfig && typeof privacyConfig.bootTimeout === 'number' && privacyConfig.bootTimeout > 0) {
|
||||
return privacyConfig.bootTimeout
|
||||
}
|
||||
const langT = (langConfig && langConfig.remote && langConfig.remote.bootTimeout) || 5000
|
||||
const themeT = (themeConfig && themeConfig.overrideRemote && themeConfig.overrideRemote.bootTimeout) || 5000
|
||||
return Math.max(langT, themeT) + 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言包
|
||||
* @param {string} lang - 语言类型
|
||||
* @returns {Promise} - 加载结果
|
||||
*/
|
||||
async loadLang(lang = langConfig.defaultLang) {
|
||||
if (this.loadedConfigs[`lang_${lang}`]) {
|
||||
return this.loadedConfigs[`lang_${lang}`];
|
||||
}
|
||||
|
||||
if (this.loadPromises[`lang_${lang}`]) {
|
||||
return this.loadPromises[`lang_${lang}`];
|
||||
}
|
||||
|
||||
this.loadPromises[`lang_${lang}`] = new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 动态加载语言包
|
||||
const langData = require(`@/lang/${lang}/common.js`).lang;
|
||||
this.loadedConfigs[`lang_${lang}`] = langData;
|
||||
resolve(langData);
|
||||
} catch (error) {
|
||||
// console.error(`加载语言包 ${lang} 失败:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return this.loadPromises[`lang_${lang}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载页面语言包
|
||||
* @param {string} lang - 语言类型
|
||||
* @param {string} pagePath - 页面路径
|
||||
* @returns {Promise} - 加载结果
|
||||
*/
|
||||
async loadPageLang(lang = langConfig.defaultLang, pagePath) {
|
||||
const key = `page_${lang}_${pagePath}`;
|
||||
if (this.loadedConfigs[key]) {
|
||||
return this.loadedConfigs[key];
|
||||
}
|
||||
|
||||
if (this.loadPromises[key]) {
|
||||
return this.loadPromises[key];
|
||||
}
|
||||
|
||||
this.loadPromises[key] = new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 动态加载页面语言包
|
||||
const pageLangData = require(`@/lang/${lang}/${pagePath}.js`).lang;
|
||||
this.loadedConfigs[key] = pageLangData;
|
||||
resolve(pageLangData);
|
||||
} catch (error) {
|
||||
console.error(`加载页面语言包 ${lang}/${pagePath} 失败:`, error);
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
|
||||
return this.loadPromises[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载主题配置(异步方式)
|
||||
* 关键: 在 remote-only 模式下, style_color.js 已被 webpack 替换为 empty-style-stub.js,
|
||||
* 只含 1 个 default preset. 其它 7 个 preset 由 ThemeOverride.loadAllPresets()
|
||||
* 异步拉取并合并到本地缓存. 这里的 loadTheme 优先从缓存取,缓存未命中时回退到 stub.
|
||||
*
|
||||
* @param {string} theme - 主题名称
|
||||
* @returns {Promise} - 加载结果
|
||||
*/
|
||||
async loadTheme(theme = themeConfig.defaultTheme) {
|
||||
// 1) 先查已加载的内存缓存(包括远端拉取后写入的)
|
||||
if (this.loadedConfigs[`theme_${theme}`]) {
|
||||
return this.loadedConfigs[`theme_${theme}`];
|
||||
}
|
||||
|
||||
// 2) remote-only 模式下,从 ThemeOverride 内存缓存(由 loadAllPresets 写入)取
|
||||
try {
|
||||
const ThemeOverride = require('./theme-override.js').default;
|
||||
const remotePresets = ThemeOverride.getAllPresets();
|
||||
if (remotePresets && remotePresets[theme]) {
|
||||
this.loadedConfigs[`theme_${theme}`] = remotePresets[theme];
|
||||
return remotePresets[theme];
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore — ThemeOverride 不可用时继续走 require stub
|
||||
}
|
||||
|
||||
// 3) 兜底: require style_color.js (local 模式全量, remote-only 模式 stub)
|
||||
if (this.loadPromises[`theme_${theme}`]) {
|
||||
return this.loadPromises[`theme_${theme}`];
|
||||
}
|
||||
|
||||
this.loadPromises[`theme_${theme}`] = new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 动态加载主题配置 (webpack 替换为 stub 时,只返回 1 个 default preset)
|
||||
const themeData = require(`@/common/js/style_color.js`)['default'][theme];
|
||||
this.loadedConfigs[`theme_${theme}`] = themeData;
|
||||
resolve(themeData);
|
||||
} catch (error) {
|
||||
console.error(`加载主题 ${theme} 失败:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return this.loadPromises[`theme_${theme}`];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConfigExternal();
|
||||
@@ -1,90 +1,32 @@
|
||||
// 发行版本,配置说明
|
||||
let releaseCfg = undefined;
|
||||
|
||||
// #ifdef APP-PLUS || MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-QQ || MP-TOUTIAO || MP-HARMONY
|
||||
try {
|
||||
if (site) { // 从site.js文件中获取配置, 针对微信小程序类
|
||||
if (site) {
|
||||
releaseCfg = {
|
||||
baseUrl: site.baseUrl,
|
||||
imgDomain: site.baseUrl,
|
||||
h5Domain: site.baseUrl,
|
||||
uniacid: site.uniacid,
|
||||
}
|
||||
} else {
|
||||
// 惰性加载 Lang 避免循环依赖(lang.js 引用了 config.js)
|
||||
try {
|
||||
const Lang = require('@/common/js/lang.js').default;
|
||||
console.warn(Lang.lang('common.config.siteJsNotConfigured'));
|
||||
} catch (e) {
|
||||
console.warn('小程序发行环境下,必须配置site.js文件,不然将使用默认配置,影响小程序定制分发!');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
// 线上版本部署域名, 注意: 必须以 '/' 结尾,适用于小程序/H5/快应用等场景
|
||||
// 默认域名, 自定义发行时可以修改
|
||||
let defaultDomain = '';
|
||||
|
||||
// #ifdef H5
|
||||
// 域名由 vue.config.js 中 DefinePlugin 注入的 process.env.UNI_APP_DOMAIN 传入
|
||||
try {
|
||||
if (process.env.UNI_APP_DOMAIN) {
|
||||
defaultDomain = process.env.UNI_APP_DOMAIN;
|
||||
}
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
// 确保域名以 '/' 结尾
|
||||
if (defaultDomain && !defaultDomain.endsWith('/')) {
|
||||
defaultDomain += '/';
|
||||
}
|
||||
|
||||
// 本地调试配置示例文件
|
||||
// 复制.local.config.js.example 并重命名为 .local.config.js 以使用自定义本地配置
|
||||
let localDevConfig = { uniacid: 0, domain: defaultDomain };
|
||||
|
||||
// #ifndef PRODUCTION
|
||||
// 尝试动态加载本地配置文件
|
||||
if (process?.env?.NODE_ENV === 'development') {
|
||||
try {
|
||||
// 尝试从项目根目录加载本地配置文件
|
||||
// 使用相对路径的方式,确保在不同平台下都能正确解析
|
||||
let customConfig = require('@/.local.config.js').default;
|
||||
if (customConfig) {
|
||||
localDevConfig = customConfig;
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果本地配置文件不存在或加载失败,保持使用默认配置
|
||||
// 只在开发模式下输出提示信息
|
||||
// #ifdef WEB
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 1.开发调试模式
|
||||
* 去掉注释 ...defaultCfg;
|
||||
* 注释掉 ...releaseCfg,
|
||||
* 2.发行/发布模式,例如通过`HBuilder>发行>小程序微信`的时候,原理是:
|
||||
* 然后将 `import site from "../site.js";`追加到 `unpackage\dist\build\mp-weixin\common\vendor.js` 文件内容开头部分
|
||||
* 然后将 site.js 文件放到 `unpackage\dist\build\mp-weixin\` 目录下面
|
||||
*/
|
||||
// 调试版本,配置说明
|
||||
const devCfg = {
|
||||
// 商户ID
|
||||
uniacid: uniacid, //825
|
||||
uniacid: 1, //825
|
||||
|
||||
//api请求地址
|
||||
baseUrl: domain,
|
||||
baseUrl: 'https://dev.aigc-quickapp.com/',
|
||||
// baseUrl: 'http://localhost:8010/',
|
||||
|
||||
// 图片域名
|
||||
imgDomain: domain,
|
||||
imgDomain: 'https://dev.aigc-quickapp.com/',
|
||||
//imgDomain: 'http://localhost:8010/',
|
||||
|
||||
// H5端域名
|
||||
h5Domain: domain,
|
||||
h5Domain: 'https://dev.aigc-quickapp.com/',
|
||||
// h5Domain: 'http://localhost:8010/',
|
||||
|
||||
// // api请求地址
|
||||
// baseUrl: 'https://tsaas.liveplatform.cn/',
|
||||
@@ -103,20 +45,31 @@ export default {
|
||||
|
||||
// // H5端域名
|
||||
// h5Domain: 'http://saas.cn/',
|
||||
};
|
||||
|
||||
// 发行版本配置
|
||||
...(releaseCfg || {}),
|
||||
var config = {
|
||||
/**
|
||||
* 1.开发调试模式
|
||||
* 去掉注释 ...devCfg;
|
||||
* 注释掉 ...releaseCfg,
|
||||
* 2.发行/发布模式,例如通过`HBuilder>发行>小程序微信`的时候,原理是:
|
||||
* 然后将 `import site from "../site.js";`追加到 `unpackage\dist\build\mp-weixin\common\vendor.js` 文件内容开头部分
|
||||
* 然后将 site.js 文件放到 `unpackage\dist\build\mp-weixin\` 目录下面
|
||||
*/
|
||||
...(releaseCfg ?? devCfg),
|
||||
|
||||
|
||||
// 腾讯地图key
|
||||
mpKey: 'TUHBZ-CNWKU-UHAVP-GZQ26-HNZFO-3YBF4',
|
||||
|
||||
// 客服地址
|
||||
//客服地址
|
||||
webSocket: '{{$webSocket}}',
|
||||
|
||||
// 本地端主动给服务器ping的时间, 0 则不开启 , 单位秒
|
||||
//本地端主动给服务器ping的时间, 0 则不开启 , 单位秒
|
||||
pingInterval: 1500,
|
||||
|
||||
// 版本号
|
||||
version: '1.0'
|
||||
};
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -2,9 +2,6 @@
|
||||
* 客服统一处理服务
|
||||
* 整合各种客服方式,提供统一的调用接口
|
||||
*/
|
||||
import IMAGE_CONFIG from './image-config.js';
|
||||
import Platform from './platform.js';
|
||||
|
||||
export class CustomerService {
|
||||
constructor(vueInstance, externalConfig = null) {
|
||||
this.vm = vueInstance;
|
||||
@@ -31,25 +28,28 @@ export class CustomerService {
|
||||
return this.latestPlatformConfig;
|
||||
}
|
||||
|
||||
// 优先级:外部传入的最新配置 > vuex配置 > 空对象
|
||||
const servicerConfig = this.externalConfig || this.vm.$store.state.servicerConfig || {};
|
||||
const platformMap = {
|
||||
h5: 'h5',
|
||||
weapp: 'weapp',
|
||||
alipay: 'aliapp',
|
||||
baidu: 'baidu',
|
||||
toutiao: 'toutiao',
|
||||
qq: 'qq',
|
||||
kuaishou: 'kuaishou',
|
||||
jd: 'jd',
|
||||
'360': '360',
|
||||
xhs: 'xhs',
|
||||
app: 'app',
|
||||
pc: 'pc'
|
||||
};
|
||||
console.log(`【实时客服配置】`, servicerConfig);
|
||||
|
||||
const platformKey = platformMap[Platform.detect()] || Platform.detect();
|
||||
let platformConfig = servicerConfig[platformKey] && typeof servicerConfig[platformKey] === 'object' ? servicerConfig[platformKey] : null;
|
||||
let platformConfig = null;
|
||||
// #ifdef H5
|
||||
platformConfig = servicerConfig.h5 ? (typeof servicerConfig.h5 === 'object' ? servicerConfig.h5 : null) : null;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
platformConfig = servicerConfig.weapp ? (typeof servicerConfig.weapp === 'object' ? servicerConfig.weapp : null) : null;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
platformConfig = servicerConfig.aliapp ? (typeof servicerConfig.aliapp === 'object' ? servicerConfig.aliapp : null) : null;
|
||||
// #endif
|
||||
|
||||
// #ifdef PC
|
||||
platformConfig = servicerConfig.pc ? (typeof servicerConfig.pc === 'object' ? servicerConfig.pc : null) : null;
|
||||
// #endif
|
||||
|
||||
// 处理空数组情况(你的配置中pc/aliapp是空数组,转为null)
|
||||
if (Array.isArray(platformConfig)) {
|
||||
platformConfig = null;
|
||||
}
|
||||
@@ -75,15 +75,6 @@ export class CustomerService {
|
||||
return config && typeof config === 'object' && config.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有有效的客服配置(用于判断是否显示客服按钮)
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasValidKefuConfig() {
|
||||
const config = this.getPlatformConfig();
|
||||
return config && typeof config === 'object' && config.type && config.type !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证客服配置完整性
|
||||
* @returns {Object} 验证结果
|
||||
@@ -91,25 +82,39 @@ export class CustomerService {
|
||||
validateConfig() {
|
||||
const config = this.getPlatformConfig();
|
||||
const wxworkConfig = this.getWxworkConfig();
|
||||
|
||||
|
||||
const result = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
if (!config || !config.type) {
|
||||
if (!config) {
|
||||
result.isValid = false;
|
||||
result.errors.push(this.vm.$lang('customerService.serviceTypeNotConfigured'));
|
||||
result.errors.push('客服配置不存在');
|
||||
return result;
|
||||
}
|
||||
|
||||
if (config.type === 'aikefu') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!config.type) {
|
||||
result.isValid = false;
|
||||
result.errors.push('客服类型未配置');
|
||||
}
|
||||
|
||||
if (config.type === 'wxwork') {
|
||||
if (!wxworkConfig || !wxworkConfig.enable) {
|
||||
result.warnings.push('企业微信未启用');
|
||||
}
|
||||
if (!wxworkConfig.contact_url) {
|
||||
result.warnings.push('企业微信活码链接未配置');
|
||||
if (!wxworkConfig) {
|
||||
result.isValid = false;
|
||||
result.errors.push('企业微信配置不存在');
|
||||
} else {
|
||||
if (!wxworkConfig.enable) {
|
||||
result.warnings.push('企业微信功能未启用');
|
||||
}
|
||||
if (!wxworkConfig.contact_url) {
|
||||
result.warnings.push('企业微信活码链接未配置,将使用原有客服方式');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,39 +122,40 @@ export class CustomerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到 AI智能客服页面(AiChat)
|
||||
* 跳转到Dify客服页面
|
||||
*/
|
||||
openAiChatService() {
|
||||
openDifyService() {
|
||||
try {
|
||||
// 清除未读数(如果存在)
|
||||
if (typeof this.vm.setAiUnreadCount === 'function') {
|
||||
if (this.vm.setAiUnreadCount) {
|
||||
this.vm.setAiUnreadCount(0);
|
||||
}
|
||||
|
||||
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
|
||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||
|
||||
// ✅ 使用 navigateTo 保留返回栈(体验更好)
|
||||
uni.navigateTo({
|
||||
url: aiChatUrl,
|
||||
// 强制跳转,忽略框架层的封装
|
||||
uni.redirectTo({
|
||||
url: '/pages_tool/ai-chat/index',
|
||||
fail: (err) => {
|
||||
console.error('跳转 AI智能客服失败:', err);
|
||||
// H5 兜底
|
||||
// 兜底:使用window.location跳转(H5)
|
||||
// #ifdef H5
|
||||
window.location.href = aiChatUrl;
|
||||
window.location.href = '/pages_tool/ai-chat/index';
|
||||
// #endif
|
||||
uni.showToast({ title: this.vm.$lang('customerService.openFailed'), icon: 'none' });
|
||||
console.error('跳转Dify客服失败:', err);
|
||||
uni.showToast({
|
||||
title: '跳转客服失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('跳转 AI智能客服异常:', e);
|
||||
uni.showToast({ title: this.vm.$lang('customerService.openFailed'), icon: 'none' });
|
||||
console.error('跳转Dify客服异常:', e);
|
||||
uni.showToast({
|
||||
title: '跳转客服失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客服点击事件(统一入口)
|
||||
* @param {Object} options 选项参数(用于消息卡片等)
|
||||
* 处理客服点击事件
|
||||
* @param {Object} options 选项参数
|
||||
*/
|
||||
handleCustomerClick(options = {}) {
|
||||
const validation = this.validateConfig();
|
||||
@@ -164,7 +170,6 @@ export class CustomerService {
|
||||
}
|
||||
|
||||
const config = this.getPlatformConfig();
|
||||
|
||||
const { niushop = {}, sendMessageTitle = '', sendMessagePath = '', sendMessageImg = '' } = options;
|
||||
|
||||
if (config.type === 'none') {
|
||||
@@ -172,10 +177,10 @@ export class CustomerService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 核心路由:根据 type 决定行为
|
||||
// 核心分支:根据最新的type处理
|
||||
switch (config.type) {
|
||||
case 'aikefu':
|
||||
this.openAiChatService();
|
||||
this.openDifyService();
|
||||
break;
|
||||
case 'wxwork':
|
||||
this.openWxworkService(false, config, options);
|
||||
@@ -183,9 +188,6 @@ export class CustomerService {
|
||||
case 'third':
|
||||
this.openThirdService(config);
|
||||
break;
|
||||
case 'miniprogram':
|
||||
this.openThirdService(config);
|
||||
break;
|
||||
case 'niushop':
|
||||
this.openNiushopService(niushop);
|
||||
break;
|
||||
@@ -196,40 +198,36 @@ export class CustomerService {
|
||||
this.openAliappService(config);
|
||||
break;
|
||||
default:
|
||||
console.error('【未知客服类型】', config.type);
|
||||
this.makePhoneCall();
|
||||
}
|
||||
}
|
||||
|
||||
// ================== 各类型客服实现 ==================
|
||||
|
||||
/**
|
||||
* 打开企业微信客服
|
||||
* @param {boolean} useOriginalService 是否使用原有客服方式
|
||||
* @param {Object} servicerConfig 客服配置
|
||||
* @param {Object} options 选项参数
|
||||
*/
|
||||
openWxworkService(useOriginalService = false, servicerConfig = null, options = {}) {
|
||||
const config = servicerConfig || this.getPlatformConfig();
|
||||
const wxworkConfig = this.getWxworkConfig();
|
||||
const { sendMessageTitle = '', sendMessagePath = '', sendMessageImg = '' } = options;
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (!useOriginalService && wxworkConfig?.enable && wxworkConfig?.contact_url) {
|
||||
if (wxworkConfig?.enable && wxworkConfig?.contact_url && !useOriginalService) {
|
||||
wx.navigateToMiniProgram({
|
||||
appId: 'wxeb490c6f9b154ef9',
|
||||
path: `pages/contacts/externalContactDetail?url=${encodeURIComponent(wxworkConfig.contact_url)}`,
|
||||
success: () => {},
|
||||
success: () => console.log('跳转企业微信成功'),
|
||||
fail: (err) => {
|
||||
console.error('跳转企业微信失败:', err);
|
||||
this.fallbackToOriginalService();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 检查是否有企业微信配置
|
||||
if (!config.wxwork_url && !config.corpid) {
|
||||
console.error('企业微信配置不完整,缺少 wxwork_url 或 corpid');
|
||||
uni.showToast({ title: this.vm.$lang('customerService.wxworkConfigIncomplete'), icon: 'none' });
|
||||
this.fallbackToPhoneCall();
|
||||
return;
|
||||
}
|
||||
wx.openCustomerServiceChat({
|
||||
extInfo: { url: config.wxwork_url || '' },
|
||||
corpId: config.corpid || '',
|
||||
extInfo: { url: config.wxwork_url },
|
||||
corpId: config.corpid,
|
||||
showMessageCard: true,
|
||||
sendMessageTitle,
|
||||
sendMessagePath,
|
||||
@@ -239,176 +237,224 @@ export class CustomerService {
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
if (!useOriginalService && wxworkConfig?.enable && wxworkConfig?.contact_url) {
|
||||
if (wxworkConfig?.enable && wxworkConfig?.contact_url) {
|
||||
window.location.href = wxworkConfig.contact_url;
|
||||
} else if (config.wxwork_url) {
|
||||
window.location.href = config.wxwork_url;
|
||||
location.href = config.wxwork_url;
|
||||
} else {
|
||||
this.fallbackToPhoneCall();
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开第三方客服
|
||||
* @param {Object} config 客服配置
|
||||
*/
|
||||
openThirdService(config) {
|
||||
|
||||
// 支持多种可能的字段名
|
||||
const miniAppId = config.mini_app_id || config.miniAppId || config.appid || config.appId || config.app_id;
|
||||
const miniAppPath = config.mini_app_path || config.miniAppPath || config.path || config.page_path || '';
|
||||
|
||||
|
||||
|
||||
// 优先处理第三方微信小程序客服
|
||||
if (miniAppId) {
|
||||
// #ifdef MP-WEIXIN
|
||||
wx.navigateToMiniProgram({
|
||||
appId: miniAppId,
|
||||
path: miniAppPath,
|
||||
success: () => {
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('【跳转第三方小程序失败】', err);
|
||||
uni.showToast({ title: this.vm.$lang('customerService.jumpFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
uni.showToast({ title: this.vm.$lang('customerService.miniProgramOnly'), icon: 'none' });
|
||||
// #endif
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理第三方链接客服
|
||||
if (config.third_url) {
|
||||
// #ifdef H5
|
||||
window.location.href = config.third_url;
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
uni.setClipboardData({
|
||||
data: config.third_url,
|
||||
success: () => {
|
||||
uni.showToast({ title: this.vm.$lang('customerService.linkCopied'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
} else {
|
||||
console.error('【第三方客服配置不完整】缺少 mini_app_id 或 third_url');
|
||||
this.fallbackToPhoneCall();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开牛商客服
|
||||
* @param {Object} niushop 牛商参数
|
||||
*/
|
||||
openNiushopService(niushop) {
|
||||
if (Object.keys(niushop).length > 0 && this.vm.$util?.redirectTo) {
|
||||
if (Object.keys(niushop).length > 0) {
|
||||
this.vm.$util.redirectTo('/pages_tool/chat/room', niushop);
|
||||
} else {
|
||||
this.makePhoneCall();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开微信小程序客服
|
||||
* @param {Object} config 客服配置
|
||||
* @param {Object} options 选项参数
|
||||
*/
|
||||
openWeappService(config, options = {}) {
|
||||
// 如果 useOfficial 为 true 或 undefined,则使用原生系统客服(由 button open-type="contact" 触发)
|
||||
// 此方法仅用于自定义跳转(如 useOfficial: false)
|
||||
if (config.useOfficial !== false) {
|
||||
// 不做任何事,应由 <button open-type="contact"> 触发
|
||||
|
||||
if (!this.shouldUseCustomService(config)) {
|
||||
console.log('使用官方微信小程序客服');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('使用自定义微信小程序客服');
|
||||
this.handleCustomWeappService(config, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自定义微信小程序客服
|
||||
* @param {Object} config 客服配置
|
||||
* @param {Object} options 选项参数
|
||||
*/
|
||||
handleCustomWeappService(config, options = {}) {
|
||||
const { sendMessageTitle = '', sendMessagePath = '', sendMessageImg = '' } = options;
|
||||
|
||||
if (config.customServiceUrl) {
|
||||
let url = config.customServiceUrl;
|
||||
const params = [];
|
||||
const { sendMessageTitle, sendMessagePath, sendMessageImg } = options;
|
||||
if (sendMessageTitle) params.push(`title=${encodeURIComponent(sendMessageTitle)}`);
|
||||
if (sendMessagePath) params.push(`path=${encodeURIComponent(sendMessagePath)}`);
|
||||
if (sendMessageImg) params.push(`img=${encodeURIComponent(sendMessageImg)}`);
|
||||
|
||||
if (params.length > 0) {
|
||||
url += (url.includes('?') ? '&' : '?') + params.join('&');
|
||||
}
|
||||
uni.navigateTo({ url });
|
||||
|
||||
uni.navigateTo({
|
||||
url: url,
|
||||
fail: (err) => {
|
||||
console.error('跳转自定义客服页面失败:', err);
|
||||
this.tryThirdPartyService(config, options);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryThirdPartyService(config, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试使用第三方客服
|
||||
* @param {Object} config 客服配置
|
||||
* @param {Object} options 选项参数
|
||||
*/
|
||||
tryThirdPartyService(config, options = {}) {
|
||||
// 支持第三方微信小程序客服
|
||||
if (config.thirdPartyMiniAppId || config.mini_app_id) {
|
||||
// #ifdef MP-WEIXIN
|
||||
wx.navigateToMiniProgram({
|
||||
appId: config.thirdPartyMiniAppId || config.mini_app_id,
|
||||
path: config.thirdPartyMiniAppPath || config.mini_app_path || ''
|
||||
});
|
||||
// #endif
|
||||
return;
|
||||
}
|
||||
|
||||
// 支持第三方链接客服
|
||||
if (config.thirdPartyServiceUrl || config.third_url) {
|
||||
const serviceUrl = config.thirdPartyServiceUrl || config.third_url;
|
||||
if (config.thirdPartyServiceUrl) {
|
||||
// #ifdef H5
|
||||
window.open(serviceUrl, '_blank');
|
||||
window.open(config.thirdPartyServiceUrl, '_blank');
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
uni.setClipboardData({ data: serviceUrl });
|
||||
uni.showToast({ title: this.vm.$lang('customerService.customerLinkCopied'), icon: 'none' });
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (config.thirdPartyMiniAppId) {
|
||||
wx.navigateToMiniProgram({
|
||||
appId: config.thirdPartyMiniAppId,
|
||||
path: config.thirdPartyMiniAppPath || '',
|
||||
fail: (err) => {
|
||||
console.error('跳转第三方小程序失败:', err);
|
||||
this.fallbackToPhoneCall();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
uni.setClipboardData({
|
||||
data: config.thirdPartyServiceUrl,
|
||||
success: () => {
|
||||
uni.showModal({
|
||||
title: '客服链接已复制',
|
||||
content: '客服链接已复制到剪贴板,请在浏览器中粘贴访问',
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.fallbackToPhoneCall();
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级到电话客服
|
||||
*/
|
||||
fallbackToPhoneCall() {
|
||||
uni.showModal({
|
||||
title: '联系客服',
|
||||
content: '在线客服暂时不可用,是否拨打电话联系客服?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.makePhoneCall();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开支付宝小程序客服
|
||||
* @param {Object} config 客服配置
|
||||
*/
|
||||
openAliappService(config) {
|
||||
if (config.type === 'aikefu') {
|
||||
this.openAiChatService();
|
||||
} else if (config.type === 'third') {
|
||||
this.openThirdService(config);
|
||||
} else {
|
||||
// 支付宝原生客服由 button open-type="contact" 触发,此处不处理
|
||||
|
||||
console.log('支付宝小程序客服', config);
|
||||
switch (config.type) {
|
||||
case 'aikefu':
|
||||
this.openDifyService();
|
||||
break;
|
||||
case 'third':
|
||||
this.openThirdService(config);
|
||||
break;
|
||||
default:
|
||||
console.log('使用支付宝官方客服');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ================== 辅助方法 ==================
|
||||
|
||||
/**
|
||||
* 拨打电话
|
||||
*/
|
||||
makePhoneCall() {
|
||||
this.vm.$api.sendRequest({
|
||||
url: '/api/site/shopcontact',
|
||||
success: res => {
|
||||
if (res.code === 0 && res.data?.mobile) {
|
||||
uni.makePhoneCall({ phoneNumber: res.data.mobile });
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: res.data.mobile
|
||||
});
|
||||
} else {
|
||||
uni.showToast({ title: this.vm.$lang('customerService.noServicePhone'), icon: 'none' });
|
||||
uni.showToast({
|
||||
title: '暂无客服电话',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: this.vm.$lang('customerService.getPhoneFailed'), icon: 'none' });
|
||||
uni.showToast({
|
||||
title: '获取客服电话失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示无客服弹窗
|
||||
*/
|
||||
showNoServicePopup() {
|
||||
const siteInfo = this.vm.$store.state.siteInfo || {};
|
||||
const message = siteInfo?.site_tel
|
||||
? this.vm.$lang('customerService.callServiceTip', { phone: siteInfo.site_tel })
|
||||
: this.vm.$lang('customerService.noServiceOffline');
|
||||
uni.showModal({ title: this.vm.$lang('customerService.contactCustomerService'), content: message, showCancel: false });
|
||||
}
|
||||
? `请联系客服,客服电话是${siteInfo.site_tel}`
|
||||
: '抱歉,商家暂无客服,请线下联系';
|
||||
|
||||
showConfigErrorPopup(errors) {
|
||||
uni.showModal({
|
||||
title: this.vm.$lang('customerService.configError'),
|
||||
content: this.vm.$lang('customerService.configErrorDetail', { errors: errors.join('\n') }),
|
||||
title: '联系客服',
|
||||
content: message,
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示配置错误弹窗
|
||||
* @param {Array} errors 错误列表
|
||||
*/
|
||||
showConfigErrorPopup(errors) {
|
||||
const message = errors.join('\n');
|
||||
uni.showModal({
|
||||
title: '配置错误',
|
||||
content: `客服配置有误:\n${message}`,
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级处理:使用原有客服方式
|
||||
*/
|
||||
fallbackToOriginalService() {
|
||||
uni.showModal({
|
||||
title: this.vm.$lang('customerService.tip'),
|
||||
content: this.vm.$lang('customerService.cannotAddWxwork'),
|
||||
title: '提示',
|
||||
content: '无法直接添加企业微信客服,是否使用其他方式联系客服?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.openWxworkService(true);
|
||||
@@ -417,19 +463,9 @@ export class CustomerService {
|
||||
});
|
||||
}
|
||||
|
||||
fallbackToPhoneCall() {
|
||||
uni.showModal({
|
||||
title: this.vm.$lang('customerService.tip'),
|
||||
content: this.vm.$lang('customerService.onlineUnavailable'),
|
||||
success: (res) => {
|
||||
if (res.confirm) this.makePhoneCall();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按钮配置(用于 template 中 v-if / open-type 判断)
|
||||
* @returns {Object}
|
||||
* 获取客服按钮配置
|
||||
* @returns {Object} 按钮配置
|
||||
*/
|
||||
getButtonConfig() {
|
||||
const config = this.getPlatformConfig();
|
||||
@@ -437,10 +473,11 @@ export class CustomerService {
|
||||
|
||||
let openType = '';
|
||||
// #ifdef MP-WEIXIN
|
||||
if (config.type === 'weapp' && config.useOfficial !== false) {
|
||||
openType = 'contact';
|
||||
if (config.type === 'weapp') {
|
||||
openType = config.useOfficial !== false ? 'contact' : '';
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
if (config.type === 'aliapp') openType = 'contact';
|
||||
// #endif
|
||||
@@ -449,234 +486,25 @@ export class CustomerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客服按钮图标
|
||||
* @returns {String} 图标URL
|
||||
* 判断是否应该使用自定义客服处理
|
||||
* @param {Object} config 客服配置
|
||||
* @returns {boolean} 是否使用自定义客服
|
||||
*/
|
||||
getKefuButtonIcon() {
|
||||
const shopInfo = this.vm.shopInfo || {};
|
||||
const config = this.getPlatformConfig();
|
||||
|
||||
// 优先使用指定的智能客服头像
|
||||
if (config?.type === 'aikefu' || config?.type === 'wxwork' || config?.type === 'qyweixin') {
|
||||
return this.vm.$util.img(IMAGE_CONFIG.aiAvatar);
|
||||
}
|
||||
|
||||
return shopInfo.kefuimg || this.vm.$util?.getDefaultImage()?.kefu || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为微信官方客服
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isWeappOfficialKefu() {
|
||||
const config = this.getPlatformConfig();
|
||||
return config?.type === 'weapp';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为智能客服
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isAIKefu() {
|
||||
const config = this.getPlatformConfig();
|
||||
return config?.type === 'aikefu';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为企业微信客服
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isWxworkKefu() {
|
||||
const config = this.getPlatformConfig();
|
||||
return config?.type === 'wxwork' || config?.type === 'qyweixin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为第三方客服
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isThirdKefu() {
|
||||
const config = this.getPlatformConfig();
|
||||
return config?.type === 'third';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要同时显示小程序系统客服
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
shouldShowExtraWeappSystemKefu() {
|
||||
const config = this.getPlatformConfig();
|
||||
return (config?.show_system_service === true || config?.show_system_service === '1') && config?.type !== 'weapp';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算容器高度
|
||||
* @param {Boolean} isLanguageSwitchEnabled 是否启用语言切换
|
||||
* @param {Boolean} showWeappSystemKefu 是否显示小程序系统客服
|
||||
* @param {Boolean} fixBtnShow 是否显示按钮
|
||||
* @returns {String} 高度值(px)
|
||||
*/
|
||||
getContainerHeight(isLanguageSwitchEnabled, showWeappSystemKefu, fixBtnShow) {
|
||||
if (!fixBtnShow) return '160px';
|
||||
|
||||
let buttonCount = 1;
|
||||
if (isLanguageSwitchEnabled) buttonCount++;
|
||||
if (showWeappSystemKefu) buttonCount++;
|
||||
|
||||
const totalRpx = 94 * buttonCount - 14;
|
||||
const pxValue = Math.round(totalRpx * 0.5);
|
||||
|
||||
return `${pxValue}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读消息数量
|
||||
* @returns {Number}
|
||||
*/
|
||||
getUnreadCount() {
|
||||
return this.vm.$store?.state?.aiUnreadCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拨打电话联系客服
|
||||
* @param {String} phoneNumber 电话号码
|
||||
*/
|
||||
makePhoneCallByNumber(phoneNumber) {
|
||||
if (phoneNumber) {
|
||||
uni.makePhoneCall({ phoneNumber });
|
||||
} else {
|
||||
this.makePhoneCall();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 AI 智能助手
|
||||
*/
|
||||
openAIChat() {
|
||||
try {
|
||||
if (typeof this.vm.setAiUnreadCount === 'function') {
|
||||
this.vm.setAiUnreadCount(0);
|
||||
}
|
||||
|
||||
const aiChatUrl = '/pages_tool/ai-chat/index';
|
||||
|
||||
uni.navigateTo({
|
||||
url: aiChatUrl,
|
||||
fail: (err) => {
|
||||
console.error('跳转 AI智能客服失败:', err);
|
||||
// #ifdef H5
|
||||
window.location.href = aiChatUrl;
|
||||
// #endif
|
||||
uni.showToast({ title: this.vm.$lang('customerService.openFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('跳转 AI智能客服异常:', e);
|
||||
uni.showToast({ title: this.vm.$lang('customerService.openFailed'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开客服选择对话框(预留方法)
|
||||
*/
|
||||
openCustomerSelectPopupDialog() {
|
||||
uni.showToast({ title: this.vm.$lang('customerService.developing'), icon: 'none' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开客服选择弹出层(ActionSheet)
|
||||
* @param {Array} kefuList 客服列表
|
||||
*/
|
||||
openKefuSelectPopup(kefuList) {
|
||||
const kefuNames = kefuList.map(item => item.name);
|
||||
uni.showActionSheet({
|
||||
itemList: kefuNames,
|
||||
success: (res) => {
|
||||
const selectedKefu = kefuList[res.tapIndex];
|
||||
const cs = createCustomerService(this.vm, selectedKefu);
|
||||
|
||||
if (selectedKefu.isOfficial) {
|
||||
uni.openCustomerServiceConversation({
|
||||
sessionFrom: 'weapp',
|
||||
showMessageCard: true
|
||||
});
|
||||
} else if (selectedKefu.id === 'qyweixin-kefu') {
|
||||
if (uni.getSystemInfoSync().platform === 'wechat') {
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/qyweixin-kefu/index'
|
||||
});
|
||||
} else {
|
||||
const qyweixinUrl = this.vm.shopInfo?.qyweixinUrl;
|
||||
if (qyweixinUrl) {
|
||||
window.location.href = qyweixinUrl;
|
||||
} else {
|
||||
uni.showToast({ title: this.vm.$lang('customerService.wxworkNotConfigured'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cs.handleCustomerClick();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心方法:统一客服入口(带验证和配置检查)
|
||||
* @param {Object} options 选项参数
|
||||
*/
|
||||
handleUnifiedKefuClick(options = {}) {
|
||||
const validation = this.validateConfig();
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error('客服配置无效:', validation.errors);
|
||||
uni.showToast({ title: this.vm.$lang('customerService.serviceUnavailable'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn('客服配置警告:', validation.warnings);
|
||||
}
|
||||
|
||||
const platformConfig = this.getPlatformConfig();
|
||||
|
||||
// 检查企业微信配置
|
||||
if (platformConfig.type === 'wxwork') {
|
||||
const wxworkConfig = this.getWxworkConfig();
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (!wxworkConfig?.enable || !wxworkConfig?.contact_url) {
|
||||
console.warn('企业微信配置不完整,使用原生客服');
|
||||
uni.showToast({ title: this.vm.$lang('customerService.wxworkConfigIncomplete'), icon: 'none' });
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
if (!wxworkConfig?.contact_url && !platformConfig.wxwork_url) {
|
||||
console.error('企业微信链接未配置');
|
||||
uni.showToast({ title: this.vm.$lang('customerService.wxworkLinkNotConfigured'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 直接调用统一处理方法,由 CustomerService 内部根据配置路由
|
||||
try {
|
||||
this.handleCustomerClick({
|
||||
sendMessageTitle: options.sendMessageTitle || this.vm.$lang('customerService.fromFloatButton'),
|
||||
sendMessagePath: options.sendMessagePath || '/pages/index/index'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('客服处理失败:', error);
|
||||
uni.showToast({ title: this.vm.$lang('customerService.openFailed'), icon: 'none' });
|
||||
shouldUseCustomService(config) {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (config?.type === 'weapp') {
|
||||
return config.useOfficial === false;
|
||||
}
|
||||
// #endif
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建客服服务实例
|
||||
* @param {Object} vueInstance Vue 实例(通常是 this)
|
||||
* @param {Object} externalConfig 可选:外部传入的最新配置(如从 DIY 数据中提取)
|
||||
* @returns {CustomerService}
|
||||
* @param {Object} vueInstance Vue实例
|
||||
* @param {Object} externalConfig 外部最新配置
|
||||
* @returns {CustomerService} 客服服务实例
|
||||
*/
|
||||
export function createCustomerService(vueInstance, externalConfig = null) {
|
||||
return new CustomerService(vueInstance, externalConfig);
|
||||
|
||||
521
common/js/diy.js
521
common/js/diy.js
@@ -1,10 +1,14 @@
|
||||
import WxMap from 'common/js/map-wx-jssdk.js';
|
||||
import { QQMapWX } from 'common/js/map-wx-jssdk.js';
|
||||
import Config from '@/common/js/config.js';
|
||||
import util from '@/common/js/util.js';
|
||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
|
||||
let systemInfo = {};
|
||||
try {
|
||||
// 合并设备信息和窗口信息
|
||||
systemInfo = {...uni.getDeviceInfo(), ...uni.getWindowInfo()};
|
||||
} catch (e) {
|
||||
// 兼容旧版本
|
||||
systemInfo = uni.getSystemInfoSync();
|
||||
}
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
@@ -38,6 +42,7 @@ export default {
|
||||
latitude: null, // 纬度
|
||||
longitude: null, // 经度
|
||||
currentPosition: '', // 当前位置
|
||||
currentStore: null,//当前门店
|
||||
nearestStore: null, // 离自己最近的门店
|
||||
|
||||
storeTimeOut: null, // 没有获取到定位,则获取默认门店
|
||||
@@ -46,30 +51,26 @@ export default {
|
||||
diyRoute: '', // 页面路由
|
||||
openBottomNav: false,
|
||||
isShowCopyRight: false,
|
||||
option: null,
|
||||
firstDiy: true,
|
||||
|
||||
// getDiyInfo 节流
|
||||
diyInfoLoading: false, // 是否正在请求
|
||||
lastDiyInfoTime: 0, // 上次成功加载的时间戳
|
||||
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
|
||||
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
|
||||
|
||||
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
|
||||
diyLoadError: false,
|
||||
diyLoadErrorMsg: '',
|
||||
diyLoadErrorUrl: '', // 出错的网络请求地址
|
||||
|
||||
//启动广告
|
||||
adv:{},
|
||||
|
||||
adv: {},
|
||||
|
||||
};
|
||||
},
|
||||
onLoad(option) {
|
||||
this.option = option;
|
||||
uni.hideTabBar();
|
||||
// 支付宝小程序传参处理
|
||||
// #ifdef MP-ALIPAY
|
||||
let aliapp_option = my.getLaunchOptionsSync();
|
||||
aliapp_option.query && Object.assign(option, aliapp_option.query);
|
||||
// #endif
|
||||
|
||||
if (option.source_member) uni.setStorageSync('source_member', option.source_member);
|
||||
|
||||
// 小程序扫码进入
|
||||
if (option.scene) {
|
||||
// 处理分享人数据
|
||||
if (option.source_member) uni.setStorageSync('source_member', option.source_member);// 分享链接进入
|
||||
if (option.scene) {// 小程序扫码进入
|
||||
var sceneParams = decodeURIComponent(option.scene);
|
||||
sceneParams = sceneParams.split('&');
|
||||
if (sceneParams.length) {
|
||||
@@ -79,64 +80,36 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// H5地图选择位置回调
|
||||
// #ifdef H5
|
||||
// H5地图选择位置回调数据
|
||||
if (option.module && option.module == 'locationPicker') {
|
||||
option.name = ''; // 清空地址
|
||||
option.name = ''; // 自定义页面传参id和name,防止获取地址时变量混淆
|
||||
this.locationModule = option.module;
|
||||
this.latitude = option.latng.split(',')[0];
|
||||
this.longitude = option.latng.split(',')[1];
|
||||
}
|
||||
// #endif
|
||||
|
||||
//自定义页面的id和名称
|
||||
this.id = option.id || 0;
|
||||
this.name = option.name || '';
|
||||
|
||||
uni.removeStorageSync('manual_store_info'); // 清除手动切换门店缓存
|
||||
uni.removeStorageSync('manual_change_store'); // 清楚手动切换门店标识
|
||||
|
||||
// H5才会执行
|
||||
if (this.locationModule == 'locationPicker') {
|
||||
|
||||
// H5地图选址后的回调
|
||||
this.getNearestStore();
|
||||
this.getCurrentLocation();
|
||||
|
||||
} else if (this.mapConfig.wap_is_open == 1) {
|
||||
|
||||
// 每次都要定位,获取当前位置
|
||||
/*this.$util.getLocation({
|
||||
fail: (res) => {
|
||||
// 拒绝定位,进入默认总店
|
||||
this.enterDefaultStore();
|
||||
}
|
||||
});*/
|
||||
|
||||
// 如果3秒没有获取到定位,则获取默认门店,H5使用
|
||||
// #ifdef H5
|
||||
this.storeTimeOut = setTimeout(() => {
|
||||
this.enterDefaultStore();
|
||||
}, 1000 * 3);
|
||||
// #endif
|
||||
|
||||
} else {
|
||||
// 关闭定位
|
||||
this.enterDefaultStore();
|
||||
//获取当前门店信息 必须是首页且不是手动切换操作
|
||||
let current_route = this.$util.getCurrentRoute();
|
||||
let manualChangeStore = uni.getStorageSync('manual_change_store'); // 手动切换门店
|
||||
if (current_route.path.indexOf('/pages/index/index') > -1 && !manualChangeStore) {
|
||||
this.getCurrentStore(option);
|
||||
}
|
||||
|
||||
},
|
||||
onShow() {
|
||||
|
||||
this.init();
|
||||
async onShow() {
|
||||
if (this.firstDiy) {
|
||||
this.firstDiy = false;
|
||||
await this.getDiyMethod();
|
||||
}
|
||||
await this.onShowMethod();
|
||||
},
|
||||
onHide() {
|
||||
if (this.storeTimeOut) {
|
||||
clearTimeout(this.storeTimeOut);
|
||||
}
|
||||
|
||||
// 跳转页面要关闭门店弹出框
|
||||
this.closeChooseStorePopup();
|
||||
|
||||
// 清除限时秒杀定时器
|
||||
this.$store.commit('setDiySeckillInterval', 0);
|
||||
},
|
||||
@@ -163,7 +136,7 @@ export default {
|
||||
return str;
|
||||
},
|
||||
backgroundUrl() {
|
||||
var str = this.diyData.global.bgUrl && this.diyData.global.bgUrl != 'transparent' ? 'url(' + this.$util.img(this.diyData.global.bgUrl) + ') ' : '';
|
||||
var str = this.diyData.global?.bgUrl && this.diyData.global?.bgUrl != 'transparent' ? 'url(' + this.$util.img(this.diyData.global?.bgUrl) + ') ' : '';
|
||||
return str;
|
||||
},
|
||||
textNavColor() {
|
||||
@@ -201,29 +174,34 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
location: function (nVal) {
|
||||
if (nVal) {
|
||||
/* location: function (nVal) {
|
||||
if (nVal && !this.latitude && !this.longitude) {
|
||||
this.latitude = nVal.latitude;
|
||||
this.longitude = nVal.longitude;
|
||||
this.getNearestStore();
|
||||
this.getCurrentLocation();
|
||||
this.getStoreInfoByLocation();
|
||||
}
|
||||
}, */
|
||||
initStatus: function (val) {
|
||||
if (!this.option.store_id) this.getLocation();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
play(){
|
||||
async getDiyMethod() {
|
||||
await this.getDiyInfo();
|
||||
|
||||
this.$store.commit('setDiySeckillInterval', 1);
|
||||
this.$store.commit('setComponentRefresh');
|
||||
},
|
||||
async init() {
|
||||
async onShowMethod() {
|
||||
|
||||
// 定位信息过期后,重新获取定位
|
||||
if(this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
|
||||
this.$util.getLocation({
|
||||
fail: (res) => {
|
||||
// 拒绝定位,进入默认总店
|
||||
this.enterDefaultStore();
|
||||
}
|
||||
});
|
||||
}
|
||||
// if (this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
|
||||
// this.$util.getLocation({
|
||||
// fail: (res) => {
|
||||
// // 失败了不需要做任何处理,保持之前的门店选择即可
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
if (this.storeToken) {
|
||||
//记录分享关系
|
||||
@@ -238,17 +216,15 @@ export default {
|
||||
|
||||
this.$store.commit('setDiySeckillInterval', 1);
|
||||
|
||||
//小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
this.$util.getMpShare().then(res => {
|
||||
this.mpShareData = res;
|
||||
});
|
||||
// #endif
|
||||
|
||||
let manualChangeStore = uni.getStorageSync('manual_change_store'); // 手动切换门店
|
||||
if (manualChangeStore) {
|
||||
uni.removeStorageSync('manual_change_store');
|
||||
|
||||
let manualStoreInfo = uni.getStorageSync('manual_store_info'); // 手动选择门店
|
||||
uni.removeStorageSync('manual_store_info');
|
||||
if (manualStoreInfo) {
|
||||
this.currentStore = manualStoreInfo;
|
||||
}
|
||||
this.closeGetLocationFailPopup();
|
||||
// 滚动至顶部
|
||||
uni.pageScrollTo({
|
||||
duration: 200,
|
||||
@@ -280,8 +256,14 @@ export default {
|
||||
query.select('.page-header').boundingClientRect(data => {
|
||||
if (data && data.height) {
|
||||
// 从状态栏高度开始算
|
||||
this.paddingTop = data.height + 'px';
|
||||
this.marginTop = -data.height + 'px';
|
||||
if (!this.diyData.global.topNavBg) {
|
||||
this.paddingTop = 0;
|
||||
this.marginTop = 0;
|
||||
} else {
|
||||
this.paddingTop = data.height + 'px';
|
||||
this.marginTop = -data.height + 'px';
|
||||
}
|
||||
|
||||
clearInterval(time);
|
||||
}
|
||||
}).exec();
|
||||
@@ -289,7 +271,7 @@ export default {
|
||||
}, 50);
|
||||
// #endif
|
||||
},
|
||||
async getDiyAdv(){
|
||||
async getDiyAdv() {
|
||||
//启动广告
|
||||
let res = await this.$api.sendRequest({
|
||||
url: '/api/diyview/getstartadv',
|
||||
@@ -298,13 +280,13 @@ export default {
|
||||
});
|
||||
this.adv = res.value
|
||||
// 弹框形式,首次弹出 1,每次弹出 0
|
||||
if(this.adv.advshow == 1){
|
||||
if (this.adv.advshow == 1) {
|
||||
setTimeout(() => {
|
||||
if (res.value.advtype == 1) {
|
||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
||||
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
||||
|
||||
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||
}
|
||||
@@ -314,127 +296,43 @@ export default {
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
||||
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
|
||||
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
|
||||
_showDiyLoadError(err, url) {
|
||||
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
|
||||
this.diyLoadErrorUrl = url || '';
|
||||
this.diyLoadError = true;
|
||||
},
|
||||
// 根据错误信息组织更详细的提示文案
|
||||
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
|
||||
_buildDiyErrorMsg(err) {
|
||||
const info = classifyHttpError(err);
|
||||
|
||||
// 服务端明确返回的业务消息(字符串):直接透传
|
||||
if (info.type === 'business') return info.raw;
|
||||
|
||||
const key = httpErrorKey(info.type);
|
||||
let reason = this.$lang(key);
|
||||
|
||||
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
|
||||
if (info.statusCode) reason += '(' + info.statusCode + ')';
|
||||
|
||||
// empty / unknown 不走模板,直接返回通用网络异常
|
||||
if (info.type === 'empty' || info.type === 'unknown') return reason;
|
||||
|
||||
const tpl = this.$lang('diy.loadFailTpl');
|
||||
return tpl.replace('{reason}', reason);
|
||||
},
|
||||
// 错误横幅「重试」入口
|
||||
retryDiyInfo() {
|
||||
this.diyLoadError = false;
|
||||
this.diyLoadErrorMsg = '';
|
||||
this.diyLoadErrorUrl = '';
|
||||
this.getDiyInfo(true);
|
||||
},
|
||||
async getDiyInfo(force = false) {
|
||||
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
|
||||
const now = Date.now();
|
||||
const currentLang = uni.getStorageSync("lang") || '';
|
||||
if (!force) {
|
||||
if (this.diyInfoLoading) return;
|
||||
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
|
||||
&& this.lastDiyInfoLang === currentLang) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.diyInfoLoading = true;
|
||||
let res;
|
||||
// 出错时用于展示的完整请求地址(Request URL),与 http.js 拼接/归一化方式保持一致
|
||||
const diyInfoUrl = '/api/diyview/info';
|
||||
const diyInfoFullUrl = ((Config.baseUrl || '') + diyInfoUrl).replace(/(?<!:)\/+/g, '/');
|
||||
try {
|
||||
res = await this.$api.sendRequest({
|
||||
url: diyInfoUrl,
|
||||
data: {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
en_type: currentLang,
|
||||
},
|
||||
async: false
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('getDiyInfo request failed', e);
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
this.diyInfoLoading = false;
|
||||
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
|
||||
this._showDiyLoadError(e, diyInfoFullUrl);
|
||||
return;
|
||||
}
|
||||
this.diyInfoLoading = false;
|
||||
|
||||
if (!res || res.code != 0 || !res.data) {
|
||||
async getDiyInfo() {
|
||||
let res = await this.$api.sendRequest({
|
||||
url: '/api/diyview/info',
|
||||
data: {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
en_type: uni.getStorageSync("lang"),//获取语言底部
|
||||
},
|
||||
async: false
|
||||
});
|
||||
if (res.code != 0 || !res.data) {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
|
||||
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
|
||||
if (!res) {
|
||||
this._showDiyLoadError(null, diyInfoFullUrl);
|
||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
if (res && res.code == -3) {
|
||||
if (res.code == -3) {
|
||||
this.$util.showToast({
|
||||
title: res.message
|
||||
});
|
||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||
this.diyData = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
|
||||
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
|
||||
this._showDiyLoadError(res && res.message, diyInfoFullUrl);
|
||||
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
|
||||
this.diyData = { ...(this.diyData || {}), value: [] };
|
||||
this.$util.showToast({
|
||||
title: '未配置自定义页面数据'
|
||||
});
|
||||
this.diyData = {};
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDiyInfoTime = now;
|
||||
this.lastDiyInfoLang = currentLang;
|
||||
// 成功加载后清除错误提示
|
||||
this.diyLoadError = false;
|
||||
this.diyLoadErrorMsg = '';
|
||||
this.diyLoadErrorUrl = '';
|
||||
|
||||
let diyDataValue = res.data;
|
||||
if (diyDataValue.value) {
|
||||
this.diyData = JSON.parse(diyDataValue.value);
|
||||
|
||||
// 导航栏标题要根据语言环境切换
|
||||
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
|
||||
const title = this.$langConfig.getField(this.diyData?.global, 'title');
|
||||
if (title) this.$langConfig.title(title);
|
||||
|
||||
this.$langConfig.title(this.diyData.global.title);
|
||||
this.mpCollect = this.diyData.global.mpCollect;
|
||||
this.setPublicShare();
|
||||
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
|
||||
if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
|
||||
// 弹框形式,首次弹出 1,每次弹出 0
|
||||
setTimeout(() => {
|
||||
if (this.diyData.global.popWindow.count == 1) {
|
||||
@@ -449,7 +347,7 @@ export default {
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||
}
|
||||
}, 500);
|
||||
}*/
|
||||
}
|
||||
|
||||
// 修改diy数据结构排序
|
||||
let searchIndex = -1;
|
||||
@@ -472,14 +370,15 @@ export default {
|
||||
this.diyData.value.splice(topCategoryIndex, 1);
|
||||
this.diyData.value.splice(0, 0, ...topCategoryData);
|
||||
this.diyData.value.splice(1, 0, ...searchData);
|
||||
} else
|
||||
} else {
|
||||
this.diyData.value.splice(0, 0, ...searchData);
|
||||
}
|
||||
} else if (searchIndex != -1 && topCategoryIndex == -1) {
|
||||
let searchData = this.diyData.value.slice(searchIndex, searchIndex + 1);
|
||||
this.diyData.value.splice(searchIndex, 1);
|
||||
this.diyData.value.splice(0, 0, ...searchData);
|
||||
}
|
||||
|
||||
this.topIndexValue = null;
|
||||
for (var i = 0; i < this.diyData.value.length; i++) {
|
||||
// 分类导航组件
|
||||
if (this.diyData.value[i].componentName == 'TopCategory') {
|
||||
@@ -499,7 +398,6 @@ export default {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// #ifdef MP
|
||||
//小程序收藏
|
||||
if (!uni.getStorageSync('isCollect') && this.diyData.global.mpCollect) {
|
||||
@@ -513,6 +411,45 @@ export default {
|
||||
this.openBottomNav = this.diyData.global.openBottomNav;
|
||||
}
|
||||
this.isShowCopyRight = true;
|
||||
|
||||
//小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
let path = this.$util.getCurrentRoute().path;
|
||||
if (path == '/pages/member/index') {
|
||||
this.mpShareData = {};
|
||||
return;
|
||||
}
|
||||
let share_path = path;
|
||||
if (this.$store.state.memberInfo && this.$store.state.memberInfo.member_id) {
|
||||
share_path = this.$util.getCurrentShareRoute(this.$store.state.memberInfo.member_id).path
|
||||
}
|
||||
let appMessageData = {
|
||||
title: this.diyData.global.weappShareTitle,
|
||||
path: share_path,
|
||||
imageUrl: this.$util.img(this.diyData.global.weappShareImage),
|
||||
success: res => { },
|
||||
fail: res => { }
|
||||
}
|
||||
let timeLineData = {
|
||||
title: this.diyData.global.weappShareTitle,
|
||||
query: share_path,
|
||||
imageUrl: this.$util.img(this.diyData.global.weappShareImage),
|
||||
}
|
||||
|
||||
this.mpShareData = {
|
||||
appMessage: appMessageData,
|
||||
timeLine: timeLineData
|
||||
};
|
||||
//console.log(this.mpShareData, 'this.mpShareData');
|
||||
|
||||
var store_info = this.$store.state.globalStoreInfo;
|
||||
if (store_info) {
|
||||
this.mpShareData.appMessage.path += (this.mpShareData.appMessage.path.indexOf('?') > -1 ? '&' : '?') + 'store_id=' + store_info.store_id;
|
||||
this.mpShareData.timeLine.query += (this.mpShareData.timeLine.query.indexOf('?') > -1 ? '&' : '?') + 'store_id=' + store_info.store_id;
|
||||
}
|
||||
//朋友圈不需要页面路径,只要要后面的参数就行
|
||||
this.mpShareData.timeLine.query = this.mpShareData.timeLine.query.split('?')[1] || '';
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
closePopupWindow() {
|
||||
@@ -527,44 +464,131 @@ export default {
|
||||
this.$util.diyRedirectTo(this.diyData.global.popWindow.link);
|
||||
this.closePopupWindow();
|
||||
},
|
||||
openChooseStorePopup() {
|
||||
if (this.globalStoreConfig && this.globalStoreConfig.confirm_popup_control == 1) {
|
||||
let storeInfo = this.globalStoreInfo;
|
||||
|
||||
// 首次进入门店,没有门店信息 || 当前位置的门店和缓存门店不一致要弹框
|
||||
if (!storeInfo || storeInfo && this.nearestStore && storeInfo.store_id != this.nearestStore.store_id) {
|
||||
if (this.$refs.chooseStorePopup) this.$refs.chooseStorePopup.open();
|
||||
/******************************************** 获取门店相关 START ***************************************************/
|
||||
/**
|
||||
* 1、分享携带门店id
|
||||
* 门店id正确 进入门店
|
||||
* 门店id错误 通过定位获取门店
|
||||
* 2、通过定位获取门店
|
||||
* 开启获取定位
|
||||
* 同意获取定位 获取最近门店 进入门店
|
||||
* 拒绝获取定位
|
||||
* 平台运营模式 进入默认门店
|
||||
* 连锁门店模式 提示获取定位失败,手动选择门店或引导去开启定位
|
||||
* 关闭获取定位
|
||||
* 平台运营模式 进入默认门店
|
||||
* 连锁门店模式 提示获取定位失败,手动选择门店
|
||||
*/
|
||||
getCurrentStore(option) {
|
||||
if (option.store_id && !isNaN(parseInt(option.store_id))) {
|
||||
this.getStoreInfoByShare(option.store_id);
|
||||
} else {
|
||||
this.getLocation();
|
||||
}
|
||||
},
|
||||
getStoreInfoByShare(store_id) {
|
||||
this.$api.sendRequest({
|
||||
url: '/api/store/info',
|
||||
data: { store_id },
|
||||
success: res => {
|
||||
if (res.code >= 0 && res.data) {
|
||||
this.changeCurrentStore(res.data);
|
||||
} else {
|
||||
this.getLocation();
|
||||
}
|
||||
},
|
||||
fail: res => {
|
||||
this.getLocation();
|
||||
}
|
||||
});
|
||||
},
|
||||
getLocation() {
|
||||
if (!this.latitude && !this.longitude && this.initStatus) {
|
||||
if (this.mapConfig.wap_is_open == 1) {
|
||||
this.$util.getLocation({
|
||||
complete: (res) => {
|
||||
if (res.latitude && res.longitude) {
|
||||
this.closeGetLocationFailPopup();
|
||||
this.latitude = res.latitude;
|
||||
this.longitude = res.longitude;
|
||||
this.getStoreInfoByLocation();
|
||||
} else {
|
||||
let is_h5 = false;
|
||||
// #ifdef H5
|
||||
is_h5 = true;
|
||||
// #endif
|
||||
if (is_h5) {
|
||||
//H5同意了也会进入失败,所以直接进入默认门店
|
||||
this.enterDefaultStore();
|
||||
} else {
|
||||
this.getLocationFail();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// #ifdef H5
|
||||
//H5有的机型可能根本不会触发getLocation的任何执行,包括success,fail,completele
|
||||
//所以这里如果等待一定时间后还是没有获取到当前门店则进入默认门店
|
||||
setTimeout(() => {
|
||||
let current_route = this.$util.getCurrentRoute();
|
||||
if (this.mapConfig.wap_is_open == 1 && !this.currentStore && current_route.path == '/pages/index/index') {
|
||||
this.enterDefaultStore();
|
||||
}
|
||||
}, 5000);
|
||||
// #endif
|
||||
} else {
|
||||
this.getLocationFail();
|
||||
}
|
||||
}
|
||||
|
||||
let manualStoreInfo = uni.getStorageSync('manual_store_info'); // 手动选择门店
|
||||
if (manualStoreInfo) {
|
||||
this.nearestStore = manualStoreInfo;
|
||||
},
|
||||
getStoreInfoByLocation() {
|
||||
if (this.latitude && this.longitude) {
|
||||
this.getNearestStore();
|
||||
this.getCurrentLocation();
|
||||
}
|
||||
},
|
||||
changeCurrentStore(store_info) {
|
||||
this.currentStore = store_info;
|
||||
this.changeStore(store_info);
|
||||
this.openChooseStorePopup();
|
||||
},
|
||||
getLocationFail() {
|
||||
if (this.globalStoreConfig.store_business == 'shop') {
|
||||
this.enterDefaultStore();
|
||||
} else {
|
||||
this.openGetLocationFailPopup();
|
||||
}
|
||||
},
|
||||
openGetLocationFailPopup() {
|
||||
if (this.$refs.getLocationFailRef) this.$refs.getLocationFailRef.open();
|
||||
},
|
||||
closeGetLocationFailPopup() {
|
||||
if (this.$refs.getLocationFailRef) this.$refs.getLocationFailRef.close();
|
||||
},
|
||||
openChooseStorePopup() {
|
||||
let globalStoreInfo = this.globalStoreInfo;
|
||||
if (this.globalStoreConfig && this.globalStoreConfig.confirm_popup_control == 1) {
|
||||
this.currentStore.show_address = this.currentStore.full_address.replace(/,/g, ' ') + ' ' + this.currentStore.address;
|
||||
if (this.$refs.chooseStorePopup) this.$refs.chooseStorePopup.open();
|
||||
}
|
||||
this.changeStore(this.nearestStore); // 切换门店数据
|
||||
},
|
||||
closeChooseStorePopup() {
|
||||
if (this.$refs.chooseStorePopup) this.$refs.chooseStorePopup.close();
|
||||
},
|
||||
// 确认进入门店
|
||||
enterStore() {
|
||||
this.closeChooseStorePopup();
|
||||
},
|
||||
// 选择其他门店
|
||||
chooseOtherStore() {
|
||||
this.$util.redirectTo('/pages_tool/store/list');
|
||||
this.closeChooseStorePopup();
|
||||
},
|
||||
// 打开地图重新选择位置
|
||||
reposition() {
|
||||
reGetLocation() {
|
||||
// #ifdef MP
|
||||
/*uni.chooseLocation({
|
||||
uni.chooseLocation({
|
||||
success: res => {
|
||||
this.latitude = res.latitude;
|
||||
this.longitude = res.longitude;
|
||||
this.currentPosition = res.name;
|
||||
this.getNearestStore();
|
||||
this.getCurrentLocation();
|
||||
this.getStoreInfoByLocation();
|
||||
},
|
||||
fail(res) {
|
||||
uni.getSetting({
|
||||
@@ -589,8 +613,7 @@ export default {
|
||||
this.latitude = res.latitude;
|
||||
this.longitude = res.longitude;
|
||||
this.currentPosition = res.name;
|
||||
this.getNearestStore();
|
||||
this.getCurrentLocation();
|
||||
this.getStoreInfoByLocation();
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
@@ -608,7 +631,7 @@ export default {
|
||||
}
|
||||
});
|
||||
}
|
||||
});*/
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -629,9 +652,7 @@ export default {
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.nearestStore = res.data;
|
||||
this.nearestStore.show_address = this.nearestStore.full_address.replace(/,/g, ' ') + ' ' + this.nearestStore.address;
|
||||
this.openChooseStorePopup();
|
||||
this.changeCurrentStore(res.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -644,7 +665,6 @@ export default {
|
||||
data.latitude = this.latitude;
|
||||
data.longitude = this.longitude;
|
||||
}
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/store/getLocation',
|
||||
data: data,
|
||||
@@ -652,7 +672,7 @@ export default {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.currentPosition = res.data.formatted_addresses.recommend; // 结合知名地点形成的描述性地址,更具人性化特点
|
||||
} else {
|
||||
this.currentPosition = this.$lang('diy.locationNotAvailable');
|
||||
this.currentPosition = '未获取到定位';
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -660,26 +680,42 @@ export default {
|
||||
// 定位失败,进入默认门店
|
||||
enterDefaultStore() {
|
||||
if (this.defaultStoreInfo) {
|
||||
if (!this.nearestStore) {
|
||||
this.nearestStore = this.defaultStoreInfo;
|
||||
this.nearestStore.show_address = this.nearestStore.full_address.replace(/,/g, ' ') + ' ' + this.nearestStore.address;
|
||||
}
|
||||
if (this.currentPosition == '') this.currentPosition = this.$lang('diy.locationNotAvailable');
|
||||
this.openChooseStorePopup();
|
||||
this.changeCurrentStore(this.defaultStoreInfo);
|
||||
}
|
||||
},
|
||||
//连锁门店未定位选择门店
|
||||
chooseStore() {
|
||||
this.$util.redirectTo('/pages_tool/store/list');
|
||||
},
|
||||
//打开手机设置重新定位
|
||||
openSetting() {
|
||||
uni.openSetting({
|
||||
success: res => {
|
||||
this.getLocation();
|
||||
}
|
||||
})
|
||||
},
|
||||
/******************************************** 获取门店相关 END ***************************************************/
|
||||
// 设置公众号分享
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + this.diyRoute;
|
||||
if (this.id) shareUrl += '?id=' + this.id;
|
||||
else if (this.name) shareUrl += '?name=' + this.name;
|
||||
var store_info = this.$store.state.globalStoreInfo;
|
||||
//if (store_info) shareUrl += '?store_id=' + store_info.store_id;
|
||||
if (shareUrl.indexOf('?') > 0) {
|
||||
shareUrl += '&';
|
||||
} else {
|
||||
shareUrl += '?';
|
||||
}
|
||||
if (this.id) shareUrl += 'id=' + this.id;
|
||||
else if (this.name) shareUrl += 'name=' + this.name;
|
||||
// alert('diydiydiy')
|
||||
this.$util.setPublicShare({
|
||||
title: this.$langConfig.getField(this.diyData.global, 'title'),
|
||||
desc: '',
|
||||
title: this.diyData.global.wechatShareTitle || this.diyData.global.title,
|
||||
desc: this.diyData.global.wechatShareDesc,
|
||||
link: shareUrl,
|
||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||
imgUrl: this.diyData.global.wechatShareImage ? this.$util.img(this.diyData.global.wechatShareImage) : this.$util.img(this.siteInfo.logo_square)
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
onPageScroll(e) {
|
||||
this.scrollTop = e.scrollTop;
|
||||
@@ -694,16 +730,17 @@ export default {
|
||||
// 下拉刷新
|
||||
onPullDownRefresh() {
|
||||
this.$store.commit('setComponentRefresh');
|
||||
this.getDiyMethod();
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh();
|
||||
}, 50);
|
||||
},
|
||||
// 分享给好友
|
||||
onShareAppMessage() {
|
||||
return this.mpShareData?.appMessage;
|
||||
return this.mpShareData.appMessage;
|
||||
},
|
||||
// 分享到朋友圈
|
||||
onShareTimeline() {
|
||||
return this.mpShareData?.timeLine;
|
||||
return this.mpShareData.timeLine;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// ============================================================
|
||||
// 空 lang 包占位
|
||||
//
|
||||
// 在 source === 'remote-only' 模式下, lang/ 目录下的所有 .js 会被
|
||||
// webpack.NormalModuleReplacementPlugin 替换为这个 stub.
|
||||
//
|
||||
// 设计要点:
|
||||
// - 导出 default 对象, 结构与真实 lang 包一致: { lang: {...} }
|
||||
// - lang 字段是空对象, 让 lang() 函数 fallback 路径不报错
|
||||
// - 不导出 named export, 避免 tree-shaking 把整个 stub 干掉
|
||||
// ============================================================
|
||||
|
||||
export default {
|
||||
lang: {}
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
// ============================================================
|
||||
// 空主题预设占位(stub)
|
||||
//
|
||||
// 在 themeConfig.overrideRemote.source === 'remote-only' 模式下,
|
||||
// style_color.js 会被 webpack.NormalModuleReplacementPlugin 替换为这个 stub.
|
||||
//
|
||||
// 目的: 主包不再包含 8 个完整 preset (~80KB), 只保留最小 emergency fallback
|
||||
// (~5KB, 仅 1 个 default preset 的核心 token).
|
||||
//
|
||||
// 设计要点:
|
||||
// 1) 导出 default 对象, 结构与 style_color.js 一致: { 'default': { ... } }
|
||||
// 2) 包含最关键的 token(main_color, aux_color, bg_color 等),
|
||||
// 保证 themeColorSet 能写入基础 CSS 变量,首屏不白屏
|
||||
// 3) 缺失的 token 由远端 /api/theme/presets 异步补齐,补齐前 var() 解析失败
|
||||
// 时浏览器会回退到 SCSS 的 fallback 值(uni-app H5 已做 rpx→px)
|
||||
// 4) 其他 7 个 preset 完全由远端提供,本地不打包
|
||||
// 5) 失败兜底: 远端不可用时, 切到非 default preset 会落到 default 的样式
|
||||
//
|
||||
// token 数量统计: 8 preset × ~50 token = ~400 token. 完整 80KB.
|
||||
// 1 preset × ~50 token = ~5KB. 节省 ~94%.
|
||||
// ============================================================
|
||||
|
||||
export default {
|
||||
'default': {
|
||||
name: 'default',
|
||||
main_color: '#F4391c',
|
||||
aux_color: '#F7B500',
|
||||
bg_color: '#FF4646',
|
||||
bg_color_shallow: '#FF4646',
|
||||
promotion_color: '#FF4646',
|
||||
promotion_aux_color: '#F7B500',
|
||||
main_color_shallow: '#FFF4F4',
|
||||
price_color: 'rgb(252,82,39)',
|
||||
btn_text_color: '#FFFFFF',
|
||||
|
||||
goods_price: 'rgb(252,82,39,1)',
|
||||
promotion_tag: '#FF4646',
|
||||
goods_card_bg: '#201A18',
|
||||
goods_card_bg_shallow: '#7C7878',
|
||||
goods_card_color: '#FFD792',
|
||||
goods_coupon: '#FC5227',
|
||||
goods_cart_num_corner: '#FC5227',
|
||||
goods_btn_color: '#FF4646',
|
||||
goods_btn_color_shallow: '#F7B500',
|
||||
|
||||
pintuan_label_bg: '#F7B500',
|
||||
pintuan_label_color: '#FFFFFF',
|
||||
pintuan_color: '#FA6400',
|
||||
pintuan_promotion_color: '#FA3A1D',
|
||||
pintuan_promotion_aux_color: '#FD9A01',
|
||||
|
||||
super_member_start_bg: '#7c7878',
|
||||
super_member_end_bg: '#201a18',
|
||||
super_member_start_text_color: '#FFDBA6',
|
||||
super_member_end_text_color: '#FFEBCA',
|
||||
|
||||
bargain_promotion_color: '#F0353E',
|
||||
bargain_promotion_aux_color: '#FD9A01',
|
||||
|
||||
seckill_promotion_color: '#F83530',
|
||||
seckill_promotion_aux_color: '#FD9A01',
|
||||
|
||||
giftcard_promotion_color: '#FF3369',
|
||||
giftcard_promotion_aux_color: '#F7B500',
|
||||
|
||||
groupby_promotion_color: '#E64136',
|
||||
groupby_promotion_aux_color: '#F7B500',
|
||||
|
||||
hover_nav_bg_color: '#FFFC',
|
||||
hover_nav_text_color: '#000',
|
||||
|
||||
// ===== DIY 分类页字号 token =====
|
||||
diy_category_tree_select_size: '28rpx',
|
||||
diy_category_tree_normal_size: '20rpx',
|
||||
diy_category_item_title_size: '26rpx',
|
||||
diy_category_item_goods_title_size:'28rpx',
|
||||
diy_category_item_name_size: '24rpx',
|
||||
diy_category_item_goods_name_size:'28rpx',
|
||||
diy_category_item_goods_tag_size: '22rpx',
|
||||
diy_category_item_consult_now_size:'26rpx',
|
||||
diy_category_item_consult_now_bg: '#FF4646',
|
||||
diy_category_item_consult_now_text: '#FFFFFF',
|
||||
diy_category_item_consult_now_padding: '12rpx 30rpx',
|
||||
diy_category_item_consult_now_radius: '40rpx',
|
||||
diy_category_item_consult_now_weight: '500',
|
||||
diy_category_item_consult_now_line_height:'1.2',
|
||||
diy_category_item_consult_now_text_align: 'center',
|
||||
diy_category_item_consult_now_white_space:'nowrap',
|
||||
diy_category_item_consult_now_display: 'inline-block',
|
||||
|
||||
// ===== DIY 分类页 - 商品卡 token =====
|
||||
diy_category_item_goods_name_line_height:1.3,
|
||||
diy_category_item_goods_name_margin_top: '20rpx',
|
||||
diy_category_item_discount_price_weight: 'bold',
|
||||
diy_category_item_discount_price_line_height: 1,
|
||||
diy_category_item_discount_price_margin_top: '16rpx',
|
||||
diy_category_item_pro_info_margin_top: '16rpx',
|
||||
diy_category_item_delete_price_size: '24rpx',
|
||||
diy_category_item_delete_price_line_height: 1,
|
||||
diy_category_item_delete_price_text_decoration:'line-through',
|
||||
diy_category_item_member_price_tag_width: '60rpx',
|
||||
diy_category_item_member_price_tag_height: '28rpx',
|
||||
diy_category_item_member_price_tag_line_height: 1,
|
||||
diy_category_item_member_price_tag_margin_left: '6rpx',
|
||||
diy_category_item_info_wrap_padding: '0 20rpx',
|
||||
diy_category_item_info_wrap_margin_top: '12rpx',
|
||||
diy_category_item_footer_wrap_display: 'flex',
|
||||
diy_category_item_footer_wrap_justify_content: 'space-between',
|
||||
diy_category_item_footer_wrap_align_items: 'normal',
|
||||
diy_category_item_footer_wrap_margin_top: 0,
|
||||
diy_category_item_price_wrap_display: 'block',
|
||||
diy_category_item_price_wrap_align_items: 'normal',
|
||||
diy_category_item_price_wrap_flex_wrap: 'nowrap',
|
||||
diy_category_item_price_wrap_justify_content: 'normal',
|
||||
diy_category_item_price_wrap_margin_top: 0,
|
||||
diy_category_item_name_wrap_flex: '1',
|
||||
diy_category_item_consult_now_wrap_display: 'flex',
|
||||
diy_category_item_consult_now_wrap_justify_content:'flex-end',
|
||||
diy_category_item_consult_now_wrap_margin_top: 'auto',
|
||||
diy_category_item_consult_now_wrap_padding_top: '10rpx',
|
||||
diy_category_item_color_tip_color: '#909399',
|
||||
diy_category_item_price_font_weight: 500,
|
||||
|
||||
// ===== 营销 token(全 8 preset 一致) =====
|
||||
pintuan_label_grad_start: '#ffbd5b',
|
||||
pintuan_label_grad_end: '#fd882e',
|
||||
pintuan_avatar_border: '#ff3d3d',
|
||||
pintuan_success_start: '#3edb73',
|
||||
pintuan_success_end: '#1db576',
|
||||
bargain_promo_shallow: '#ffeadb',
|
||||
seckill_price_shallow: '#fcecd7',
|
||||
seckill_price_start: '#fdbe6c',
|
||||
seckill_discount_start: '#ff5f17',
|
||||
seckill_discount_end: '#fe2f18',
|
||||
member_card_gold_start: '#E3B66B',
|
||||
member_card_gold_end: '#F7DAA5',
|
||||
member_icon_grad_start: '#814635',
|
||||
member_icon_grad_end: '#3A221B',
|
||||
balance_page_bg_start: '#FE7849',
|
||||
balance_page_bg_end: '#FF1959',
|
||||
point_page_bg_start: '#FE7849',
|
||||
point_page_bg_end: '#FF1959',
|
||||
point_promo_start: '#F8F8F8',
|
||||
point_promo_end: '#E74A32',
|
||||
point_item_grad_start: '#F4402B',
|
||||
point_item_grad_end: '#FD7C40',
|
||||
invite_grad_start: '#ff2440',
|
||||
invite_grad_end: '#ff7b7b',
|
||||
promote_grad_start: '#ffe2ac',
|
||||
promote_grad_end: '#fdc174',
|
||||
fenxiao_withdraw_start: '#ff3c29',
|
||||
fenxiao_withdraw_end: '#ff6f29',
|
||||
level_1_grad_start: '#9BA7B1', level_1_grad_end: '#D3DEE6',
|
||||
level_2_grad_start: '#F57E2A', level_2_grad_end: '#FAD494',
|
||||
level_3_grad_start: '#F85151', level_3_grad_end: '#FF9999',
|
||||
level_4_grad_start: '#78B8B4', level_4_grad_end: '#AFE6E2',
|
||||
level_5_grad_start: '#4DA1E1', level_5_grad_end: '#58CBF0',
|
||||
level_6_grad_start: '#81C636', level_6_grad_end: '#D1F677',
|
||||
level_7_grad_start: '#6D7279', level_7_grad_end: '#A5AAB0',
|
||||
level_8_grad_start: '#866DDB', level_8_grad_end: '#D49BFE',
|
||||
level_9_grad_start: '#f1c74e', level_9_grad_end: '#f7dc81',
|
||||
level_10_grad_start: '#418CCF', level_10_grad_end: '#9CC6F1',
|
||||
}
|
||||
// 其他 7 个 preset (green/blue/pink/gold/purple/yellow/black)
|
||||
// 全部由远端 /api/theme/presets 异步提供
|
||||
// 远端拉取前切到非 default preset,会回退到 default 样式
|
||||
// 注: 历史曾尝试添加 'red' preset, 但前端 style_color.js 从未定义, 已在
|
||||
// config-external.js themeList 中移除以保持一致性
|
||||
};
|
||||
@@ -12,13 +12,57 @@
|
||||
* @createTime 2023-08-10
|
||||
*/
|
||||
|
||||
import Platform from './platform.js'
|
||||
|
||||
class EventBus {
|
||||
constructor() {
|
||||
this.events = new Map()
|
||||
this.domBridge = null
|
||||
this.platform = Platform.detect()
|
||||
this.platform = this.detectPlatform()
|
||||
}
|
||||
|
||||
// 检测平台
|
||||
detectPlatform() {
|
||||
// #ifdef H5
|
||||
return 'h5'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'weapp'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
return 'alipay'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-BAIDU
|
||||
return 'baidu'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-TOUTIAO
|
||||
return 'toutiao'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-QQ
|
||||
return 'qq'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-KUAISHOU
|
||||
return 'kuaishou'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-JD
|
||||
return 'jd'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-360
|
||||
return '360'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-XHS
|
||||
return 'xhs'
|
||||
// #endif
|
||||
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// 设置 DOM 桥接
|
||||
@@ -66,7 +110,7 @@ class EventBus {
|
||||
|
||||
// 异步触发事件,支持取消传播,并支持在监听处理后调用异步函数 defaultAsyncHandler
|
||||
// 使用方式:await eventBus.emit(eventName, data, defaultAsyncHandler)
|
||||
// defaultAsyncHandler 会在监听器执行后被调用,签名为 defaultAsyncHandler(event, handler, handlerResult)
|
||||
// defaultAsyncHandler 会在每个监听器执行后被调用,签名为 defaultAsyncHandler(event, handler, handlerResult)
|
||||
// 如果 defaultAsyncHandler 返回 Promise,会等待其 resolve
|
||||
// 如果 allowContinuePropagation 为 false,事件触发后将立即返回,不继续传播
|
||||
// 如果 stopOnError 为 true,在任意监听器抛出错误后立即停止传播
|
||||
@@ -79,7 +123,6 @@ class EventBus {
|
||||
|
||||
// helper to call defaultAsyncHandler only when allowed
|
||||
const callDefaultAsyncHandler = async (evt, handler, handlerResult) => {
|
||||
// console.log('callDefaultAsyncHandler', {evt, handler, handlerResult});
|
||||
if (typeof defaultAsyncHandler !== 'function' || !allowAsyncHandlerRun) return
|
||||
try {
|
||||
const res = defaultAsyncHandler(evt, handler, handlerResult)
|
||||
@@ -109,7 +152,7 @@ class EventBus {
|
||||
return !event.defaultPrevented
|
||||
}
|
||||
// 否则继续走普通 EventBus 处理(fallthrough)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通 EventBus 触发
|
||||
const handlers = this.events.get(eventName)
|
||||
@@ -146,7 +189,6 @@ class EventBus {
|
||||
}
|
||||
|
||||
// 在每个监听器执行后,如果允许并且提供了 defaultAsyncHandler,就调用并等待它
|
||||
// console.log('==> callDefaultAsyncHandler', {event, handler, awaitedResult})
|
||||
await callDefaultAsyncHandler(event, handler, awaitedResult)
|
||||
} catch (error) {
|
||||
console.error(`EventBus ${eventName} error:`, error)
|
||||
|
||||
@@ -44,7 +44,7 @@ export class EventSafety {
|
||||
// 安全的方法
|
||||
matches: (selector) => EventSafety.safeMatches(target, selector),
|
||||
// 数据集
|
||||
dataset: target?.dataset || {}
|
||||
dataset: target.dataset || {}
|
||||
}
|
||||
|
||||
return safeTarget
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* form-platform-mixin.js
|
||||
*
|
||||
* 表单组件共用的平台检测 mixin
|
||||
*
|
||||
* 集中维护 3 个 computed:
|
||||
* - platform 当前运行平台 key(与后端 formConfig.terminal 键名对齐)
|
||||
* - platformAliases 后端键名 → 内部 platform 名 兼容层
|
||||
* - platformConfigKeys 当前平台的有效配置键集合(用于从 formConfig.terminal 查找)
|
||||
*
|
||||
* 消费方:
|
||||
* - components/ns-form/ns-form.vue
|
||||
* - components/ns-newform/ns-newform.vue
|
||||
* - 任何需要按端读取 formConfig.terminal.{h5|weapp|harmony|app} 的表单组件
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 单一真值:避免 ns-form / ns-newform 各自维护导致漂移
|
||||
* 2. 纯 computed mixin:不依赖 props/data/methods,便于跨组件复用
|
||||
* 3. 不依赖 util.js 等其它模块,避免循环依赖
|
||||
* 4. 通过 mixins: [...] 引入,组件侧不再写平台判断代码
|
||||
*
|
||||
* 用法:
|
||||
* import formPlatformMixin from '@/common/js/form-platform-mixin.js';
|
||||
* export default {
|
||||
* mixins: [formPlatformMixin],
|
||||
* computed: {
|
||||
* config() {
|
||||
* const cur = this.platform;
|
||||
* const keys = this.platformConfigKeys;
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
/**
|
||||
* 当前运行平台标识(与后端 formConfig.terminal 键名对齐)
|
||||
* 后端 terminal 键:h5 / weapp / harmony / app / mp-alipay / mp-baidu / mp-toutiao
|
||||
*/
|
||||
platform() {
|
||||
// #ifdef H5
|
||||
return 'h5';
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'weapp';
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
return 'mp-alipay';
|
||||
// #endif
|
||||
// #ifdef MP-BAIDU
|
||||
return 'mp-baidu';
|
||||
// #endif
|
||||
// #ifdef MP-TOUTIAO
|
||||
return 'mp-toutiao';
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
return 'app';
|
||||
// #endif
|
||||
// #ifdef MP-HARMONY
|
||||
return 'harmony';
|
||||
// #endif
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
return 'quickapp';
|
||||
// #endif
|
||||
return 'h5';
|
||||
},
|
||||
/**
|
||||
* 后端键名 → 内部 platform 名 兼容层
|
||||
* canonical 与 platform() 返回值一致;别名为兼容 DB 旧 app_type(upgrade-to-ai-agent.sql)
|
||||
* 及 uniapp / 设计器的历史命名。键名严格区分大小写。
|
||||
*/
|
||||
platformAliases() {
|
||||
return {
|
||||
'h5': 'h5',
|
||||
'weapp': 'weapp',
|
||||
'mp-weixin': 'weapp',
|
||||
'wechat': 'weapp',
|
||||
'harmony': 'harmony',
|
||||
'app': 'app',
|
||||
'app-plus': 'app',
|
||||
'mp-alipay': 'mp-alipay',
|
||||
'aliapp': 'mp-alipay',
|
||||
'alipay': 'mp-alipay',
|
||||
'mp-baidu': 'mp-baidu',
|
||||
'baiduapp': 'mp-baidu',
|
||||
'baidu': 'mp-baidu',
|
||||
'mp-toutiao': 'mp-toutiao',
|
||||
'toutiao': 'mp-toutiao'
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 当前平台的有效配置键集合
|
||||
* 用于 config() 中查找 formConfig.terminal 下的平台差异化配置:
|
||||
* - 后端格式:formConfig.terminal = { h5: {...}, weapp: {...}, harmony: {...} }
|
||||
* - 旧式扁平:formConfig[platformKey] = { ... }
|
||||
* 返回所有映射到当前 platform 的后端键名,方便两种结构都能命中。
|
||||
*/
|
||||
platformConfigKeys() {
|
||||
const cur = this.platform;
|
||||
const aliases = this.platformAliases;
|
||||
const keys = [];
|
||||
Object.keys(aliases).forEach(k => {
|
||||
if (aliases[k] === cur) keys.push(k);
|
||||
});
|
||||
// 加入当前平台名(兼容 formConfig[platform] 这种扁平写法)
|
||||
if (keys.indexOf(cur) === -1) keys.push(cur);
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,315 +1,190 @@
|
||||
import { Platform } from './platform.js';
|
||||
|
||||
// 内部 helper:获取当前页面实例
|
||||
const _getCurrentPage = () => {
|
||||
const pages = getCurrentPages();
|
||||
return pages.length === 0 ? null : pages[pages.length - 1];
|
||||
};
|
||||
|
||||
// 内部 helper:获取当前页面路由(统一提取 route 或 $page.path)
|
||||
const _getCurrentRoute = (currentPage) => {
|
||||
if (!currentPage) return '';
|
||||
if (currentPage.route) return currentPage.route;
|
||||
if (currentPage.$page && currentPage.$page.path) return currentPage.$page.path;
|
||||
return '';
|
||||
};
|
||||
|
||||
// 内部 helper:判断 tabBarList 是否已加载且有 list 配置
|
||||
const _isValidTabBarList = (tabBarList) => {
|
||||
return tabBarList && tabBarList.list;
|
||||
};
|
||||
|
||||
// 内部 helper:规范化路由:无前导斜杠时补全
|
||||
const _normalizeLeadingSlash = (route) => {
|
||||
if (!route) return route;
|
||||
return route.startsWith('/') ? route : '/' + route;
|
||||
};
|
||||
|
||||
// 内部 helper:通过 $util 方法的安全调用
|
||||
const _callUtil = (vm, method, ...args) => {
|
||||
if (vm.$util && vm.$util[method]) {
|
||||
return vm.$util[method](...args);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default {
|
||||
onLoad() { },
|
||||
onShow() {
|
||||
// remote-only 小程序/鸿蒙: boot 未完成时跳过 refresh,避免空翻译写入标题等
|
||||
// H5 不受影响(main.js 已 await boot 后才 $mount)
|
||||
if (this.$langConfig.isRemoteOnly && this.$langConfig.isRemoteOnly()) {
|
||||
if (!this.$langConfig.isBootCompleted || !this.$langConfig.isBootCompleted()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新多语言(仅刷新标题,不触发全页面 $forceUpdate)
|
||||
// 全量刷新仅在语言包真正变化时由 lang.js emitUpdated 触发
|
||||
this.$langConfig.refresh(false);
|
||||
|
||||
// TabBar 根页面时隐藏系统导航栏的返回按钮
|
||||
// (uni-app 的系统导航栏在通过 navigateTo 跳转到 tab 页时可能残留返回键)
|
||||
const tabBarList = this.$store.state.tabBarList;
|
||||
if (!_isValidTabBarList(tabBarList)) return;
|
||||
const currentRoute = _getCurrentRoute(_getCurrentPage());
|
||||
if (!currentRoute) return;
|
||||
const isRoot = _callUtil(this, 'isTabBarRootPage', currentRoute, tabBarList) || false;
|
||||
if (isRoot) {
|
||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-ALIPAY
|
||||
try { uni.hideHomeButton(); } catch (e) { /* 兼容 */ }
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 是否是英文环境
|
||||
isEnEnv() {
|
||||
return this.$langConfig.getCurrentLocale() === 'en-us';
|
||||
},
|
||||
// 是否是非中文环境(用于多语言商品名/字段切换)
|
||||
isNotZhEnv() {
|
||||
return this.$langConfig.getCurrentLocale() !== 'zh-cn';
|
||||
},
|
||||
// 动态判断:当前页面是否是 TabBar 根页面(tab 根页不应显示返回按钮)
|
||||
isTabRootPage() {
|
||||
const tabBarList = this.$store.state.tabBarList;
|
||||
if (!_isValidTabBarList(tabBarList)) return false;
|
||||
const currentRoute = _getCurrentRoute(_getCurrentPage());
|
||||
if (!currentRoute) return false;
|
||||
return _callUtil(this, 'isTabBarRootPage', currentRoute, tabBarList) || false;
|
||||
},
|
||||
themeStyle() {
|
||||
return this.$store.state.themeStyle;
|
||||
},
|
||||
themeColor() {
|
||||
return this.$store.state.themeColor;
|
||||
},
|
||||
// 插件是否存在
|
||||
addonIsExist() {
|
||||
return this.$store.state.addonIsExist;
|
||||
},
|
||||
tabBarList() {
|
||||
return this.$store.state.tabBarList;
|
||||
},
|
||||
shouldShowTabBar() {
|
||||
const tabBarList = this.$store.state.tabBarList;
|
||||
if (!_isValidTabBarList(tabBarList)) return false;
|
||||
const mode = tabBarList.page_visibility_mode;
|
||||
const routeList = tabBarList.page_routes || [];
|
||||
if (!mode || mode === 'all') return true;
|
||||
if (mode === 'none') return false;
|
||||
const currentPage = _getCurrentPage();
|
||||
if (!currentPage) return true;
|
||||
const currentRoute = _normalizeLeadingSlash(_getCurrentRoute(currentPage));
|
||||
if (!currentRoute) return true;
|
||||
const inList = _callUtil(this, 'isPageInRouteList', currentRoute, routeList) || false;
|
||||
if (mode === 'whitelist') return inList;
|
||||
if (mode === 'blacklist') return !inList;
|
||||
return true;
|
||||
},
|
||||
tabBarPagePaddingStyle() {
|
||||
return this.$util.objToStyle({
|
||||
height: `calc(100vh - ${this.tabBarHeight})`,
|
||||
paddingBottom: this.tabBarHeight
|
||||
});
|
||||
},
|
||||
siteInfo() {
|
||||
return this.$store.state.siteInfo;
|
||||
},
|
||||
memberInfo() {
|
||||
return this.$store.state.memberInfo;
|
||||
},
|
||||
storeToken() {
|
||||
return this.$store.state.token;
|
||||
},
|
||||
bottomNavHidden() {
|
||||
return this.$store.state.bottomNavHidden;
|
||||
},
|
||||
globalStoreConfig() {
|
||||
return this.$store.state.globalStoreConfig;
|
||||
},
|
||||
globalStoreInfo() {
|
||||
return this.$store.state.globalStoreInfo;
|
||||
},
|
||||
// 定位信息
|
||||
location() {
|
||||
return this.$store.state.location;
|
||||
},
|
||||
// 定位信息(缓存)
|
||||
locationStorage() {
|
||||
let data = uni.getStorageSync('location');
|
||||
if (data) {
|
||||
var date = new Date();
|
||||
if (this.mapConfig.wap_valid_time > 0) {
|
||||
data.is_expired = (date.getTime() / 1000) > data.valid_time; // 是否过期
|
||||
} else {
|
||||
data.is_expired = false;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
// 默认总店(定位失败后使用)
|
||||
defaultStoreInfo() {
|
||||
return this.$store.state.defaultStoreInfo;
|
||||
},
|
||||
// 组件刷新计数
|
||||
componentRefresh() {
|
||||
return this.$store.state.componentRefresh;
|
||||
},
|
||||
// AI客服配置
|
||||
globalAIKefuConfig() {
|
||||
return this.$store.state.globalAIKefuConfig;
|
||||
},
|
||||
// 客服配置
|
||||
servicerConfig() {
|
||||
return this.$store.state.servicerConfig;
|
||||
},
|
||||
// 企业微信配置
|
||||
wxworkConfig() {
|
||||
return this.$store.state.wxworkConfig;
|
||||
},
|
||||
diySeckillInterval() {
|
||||
return this.$store.state.diySeckillInterval;
|
||||
},
|
||||
tabBarHeight() {
|
||||
return this.shouldShowTabBar ? this.$store.state.tabBarHeight : '0rpx';
|
||||
},
|
||||
mapConfig() {
|
||||
return this.$store.state.mapConfig;
|
||||
},
|
||||
copyright() {
|
||||
let copyright = this.$store.state.copyright;
|
||||
// 判断是否授权
|
||||
if (copyright && !copyright.auth) {
|
||||
copyright.logo = '';
|
||||
copyright.copyright_link = '';
|
||||
}
|
||||
return copyright;
|
||||
},
|
||||
cartList() {
|
||||
return this.$store.state.cartList;
|
||||
},
|
||||
cartIds() {
|
||||
return this.$store.state.cartIds;
|
||||
},
|
||||
cartNumber() {
|
||||
return this.$store.state.cartNumber;
|
||||
},
|
||||
cartMoney() {
|
||||
return this.$store.state.cartMoney;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 当前平台类型(多端翻译隔离)
|
||||
* 复用 Platform.getAppType(),与服务端 /api/BaseApi 的 $app_type 对齐
|
||||
*/
|
||||
getAppType() {
|
||||
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
|
||||
},
|
||||
|
||||
// 颜色变浅(>0)、变深函数(<0)
|
||||
lightenDarkenColor(color, amount) {
|
||||
|
||||
var usePound = false;
|
||||
|
||||
if (color[0] == "#") {
|
||||
color = color.slice(1);
|
||||
usePound = true;
|
||||
}
|
||||
|
||||
var num = parseInt(color, 16);
|
||||
|
||||
var r = (num >> 16) + amount;
|
||||
|
||||
if (r > 255) r = 255;
|
||||
else if (r < 0) r = 0;
|
||||
|
||||
var b = ((num >> 8) & 0x00FF) + amount;
|
||||
|
||||
if (b > 255) b = 255;
|
||||
else if (b < 0) b = 0;
|
||||
|
||||
var g = (num & 0x0000FF) + amount;
|
||||
|
||||
if (g > 255) g = 255;
|
||||
else if (g < 0) g = 0;
|
||||
|
||||
return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16);
|
||||
|
||||
},
|
||||
/**
|
||||
* 切换门店
|
||||
* @param {Object} info 门店信息
|
||||
* @param {Object} isJump 是否跳转到首页
|
||||
*/
|
||||
changeStore(info, isJump) {
|
||||
if (info) {
|
||||
this.$store.commit('setGlobalStoreInfo', info);
|
||||
}
|
||||
let route = this.$util.getCurrRoute();
|
||||
if (isJump && route != 'pages/index/index') {
|
||||
uni.setStorageSync('manual_change_store', true); // 手动切换门店
|
||||
this.$store.dispatch('getCartNumber'); //重新获取购物车数据
|
||||
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 金额格式化输出
|
||||
* @param {Object} money
|
||||
*/
|
||||
moneyFormat(money) {
|
||||
if (isNaN(parseFloat(money))) return money;
|
||||
return parseFloat(money).toFixed(2);
|
||||
},
|
||||
|
||||
/**
|
||||
* 重新定向到登录授权, 不区分生命周期
|
||||
* @param {String} backUrl
|
||||
* @param {Object} params 附加参数
|
||||
* @param {Function} effectFnc 副作用函数, 登录授权失败的处理方式
|
||||
*/
|
||||
redirectTologin(backUrl = '', params = {}, effectFnc = ()=>{}) {
|
||||
console.log(`------> redirectTologin`, backUrl);
|
||||
setTimeout(() => {
|
||||
this.$nextTick(() => {
|
||||
let mode = 'navigateTo';
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
mode = 'redirectTo'; // H5必须使用redirectTo, 不然点击返回箭头无法返回。
|
||||
// #endif
|
||||
this.$refs?.login?.open(backUrl, mode, effectFnc);
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 重新定向到登录授权, 当页面显示的时候, 失败后,
|
||||
* 原因:onLoad、onReady 只执行一次;
|
||||
* 而且,有的页面不需要这个功能
|
||||
* @param {String} backUrl
|
||||
*/
|
||||
redirectTologinOnShowLifecycle(backUrl) {
|
||||
console.log(`------> redirectTologinOnShowLifecycle`, backUrl);
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
this.redirectTologin(backUrl);
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
this.redirectTologin(backUrl, {}, () => {
|
||||
uni.navigateBack({delta: 1 });
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
/**
|
||||
* 金额格式化输出
|
||||
* @param {Object} money
|
||||
*/
|
||||
moneyFormat(money) {
|
||||
if (isNaN(parseFloat(money))) return money;
|
||||
return parseFloat(money).toFixed(2);
|
||||
}
|
||||
}
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 页面样式,动态设置主色调
|
||||
themeColor: '' //''--base-color:#fa5d14;--base-help-color:#ff7e00;'
|
||||
}
|
||||
},
|
||||
onLoad() {},
|
||||
onShow() {
|
||||
// 刷新多语言
|
||||
this.$langConfig.refresh();
|
||||
let time = setInterval(() => {
|
||||
let theme = this.themeStyle;
|
||||
if (theme && theme.main_color) {
|
||||
this.themeColorSet();
|
||||
clearInterval(time);
|
||||
}
|
||||
}, 50);
|
||||
},
|
||||
computed: {
|
||||
themeStyle() {
|
||||
return this.$store.state.themeStyle;
|
||||
},
|
||||
// 插件是否存在
|
||||
addonIsExist() {
|
||||
return this.$store.state.addonIsExist;
|
||||
},
|
||||
tabBarList() {
|
||||
return this.$store.state.tabBarList;
|
||||
},
|
||||
siteInfo() {
|
||||
return this.$store.state.siteInfo;
|
||||
},
|
||||
memberInfo() {
|
||||
return this.$store.state.memberInfo;
|
||||
},
|
||||
storeToken() {
|
||||
return this.$store.state.token;
|
||||
},
|
||||
bottomNavHidden() {
|
||||
return this.$store.state.bottomNavHidden;
|
||||
},
|
||||
globalStoreConfig() {
|
||||
return this.$store.state.globalStoreConfig;
|
||||
},
|
||||
globalStoreInfo() {
|
||||
return this.$store.state.globalStoreInfo;
|
||||
},
|
||||
// 定位信息
|
||||
location() {
|
||||
return this.$store.state.location;
|
||||
},
|
||||
// 定位信息(缓存)
|
||||
locationStorage() {
|
||||
let data = uni.getStorageSync('location');
|
||||
if (data) {
|
||||
var date = new Date();
|
||||
if (this.mapConfig.wap_valid_time > 0) {
|
||||
data.is_expired = (date.getTime() / 1000) > data.valid_time; // 是否过期
|
||||
} else {
|
||||
data.is_expired = false;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
// 默认总店(定位失败后使用)
|
||||
defaultStoreInfo() {
|
||||
return this.$store.state.defaultStoreInfo;
|
||||
},
|
||||
// 组件刷新计数
|
||||
componentRefresh() {
|
||||
return this.$store.state.componentRefresh;
|
||||
},
|
||||
// 客服配置
|
||||
servicerConfig() {
|
||||
return this.$store.state.servicerConfig;
|
||||
},
|
||||
// 企业微信配置
|
||||
wxworkConfig() {
|
||||
return this.$store.state.wxworkConfig;
|
||||
},
|
||||
diySeckillInterval() {
|
||||
return this.$store.state.diySeckillInterval;
|
||||
},
|
||||
tabBarHeight() {
|
||||
return this.$store.state.tabBarHeight;
|
||||
},
|
||||
mapConfig() {
|
||||
return this.$store.state.mapConfig;
|
||||
},
|
||||
copyright() {
|
||||
let copyright = this.$store.state.copyright;
|
||||
// 判断是否授权
|
||||
if (copyright && !copyright.auth) {
|
||||
copyright.logo = '';
|
||||
copyright.copyright_link = '';
|
||||
}
|
||||
return copyright;
|
||||
},
|
||||
cartList() {
|
||||
return this.$store.state.cartList;
|
||||
},
|
||||
cartIds() {
|
||||
return this.$store.state.cartIds;
|
||||
},
|
||||
cartNumber() {
|
||||
return this.$store.state.cartNumber;
|
||||
},
|
||||
cartMoney() {
|
||||
return this.$store.state.cartMoney;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
themeColorSet() {
|
||||
let theme = this.themeStyle;
|
||||
this.themeColor = `--base-color:${theme.main_color};--base-help-color:${theme.aux_color};`;
|
||||
if (this.tabBarHeight != '56px') this.themeColor += `--tab-bar-height:${this.tabBarHeight};`
|
||||
Object.keys(theme).forEach(key => {
|
||||
let data = theme[key];
|
||||
if (typeof(data) == "object") {
|
||||
Object.keys(data).forEach(k => {
|
||||
this.themeColor += '--' + k.replace(/_/g, "-") + ':' + data[k] + ';';
|
||||
});
|
||||
} else if (typeof(key) == "string" && key) {
|
||||
this.themeColor += '--' + key.replace(/_/g, "-") + ':' + data + ';';
|
||||
}
|
||||
});
|
||||
for (let i = 9; i >= 5; i--) {
|
||||
let color = this.$util.colourBlend(theme.main_color, '#ffffff', (i / 10));
|
||||
this.themeColor += `--base-color-light-${i}:${color};`;
|
||||
}
|
||||
},
|
||||
// 颜色变浅(>0)、变深函数(<0)
|
||||
lightenDarkenColor(color, amount) {
|
||||
|
||||
var usePound = false;
|
||||
|
||||
if (color[0] == "#") {
|
||||
color = color.slice(1);
|
||||
usePound = true;
|
||||
}
|
||||
|
||||
var num = parseInt(color, 16);
|
||||
|
||||
var r = (num >> 16) + amount;
|
||||
|
||||
if (r > 255) r = 255;
|
||||
else if (r < 0) r = 0;
|
||||
|
||||
var b = ((num >> 8) & 0x00FF) + amount;
|
||||
|
||||
if (b > 255) b = 255;
|
||||
else if (b < 0) b = 0;
|
||||
|
||||
var g = (num & 0x0000FF) + amount;
|
||||
|
||||
if (g > 255) g = 255;
|
||||
else if (g < 0) g = 0;
|
||||
|
||||
return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16);
|
||||
|
||||
},
|
||||
/**
|
||||
* 切换门店
|
||||
* @param {Object} info 门店信息
|
||||
* @param {Object} isJump 是否跳转到首页
|
||||
*/
|
||||
changeStore(info, isJump) {
|
||||
if (info) {
|
||||
this.$store.commit('setGlobalStoreInfo', info);
|
||||
}
|
||||
let route = this.$util.getCurrRoute();
|
||||
if (isJump && route != 'pages/index/index') {
|
||||
uni.setStorageSync('manual_change_store', true); // 手动切换门店
|
||||
this.$store.dispatch('getCartNumber'); //重新获取购物车数据
|
||||
this.$util.redirectTo('/pages/index/index');
|
||||
}
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
/**
|
||||
* 金额格式化输出
|
||||
* @param {Object} money
|
||||
*/
|
||||
moneyFormat(money) {
|
||||
if (isNaN(parseFloat(money))) return money;
|
||||
return parseFloat(money).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,442 +1,382 @@
|
||||
// 商品详情业务
|
||||
import htmlParser from '@/common/js/html-parser';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
skuId: 0,
|
||||
goodsId: 0,
|
||||
// 商品详情
|
||||
goodsSkuDetail: {
|
||||
goods_id: 0,
|
||||
goods_service: []
|
||||
},
|
||||
preview: 0, //是否开启预览,0:不开启,1:开启
|
||||
//评价
|
||||
contactData: {
|
||||
title: '',
|
||||
path: '',
|
||||
img: ''
|
||||
},
|
||||
|
||||
shareQuery: '', // 分享参数
|
||||
shareUrl: '', // 分享链接
|
||||
|
||||
source_member: 0, //分享人的id
|
||||
chatRoomParams: {}, // 联系客服参数
|
||||
isIphoneX: false, //判断手机是否是iphoneX以上
|
||||
whetherCollection: 0,
|
||||
posterParams: {}, //海报所需参数
|
||||
shareImg: '',
|
||||
navbarData: {
|
||||
title: '',
|
||||
topNavColor: "#ffffff",
|
||||
topNavBg: false,
|
||||
navBarSwitch: true, // 导航栏是否显示
|
||||
textNavColor: "#333333",
|
||||
moreLink: {
|
||||
name: ""
|
||||
},
|
||||
navStyle: 1,
|
||||
bgUrl: '',
|
||||
textImgPosLink: 'left'
|
||||
},
|
||||
}
|
||||
},
|
||||
onLoad(data) {
|
||||
//刷新多语言
|
||||
this.$langConfig.refresh();
|
||||
// #ifdef MP-ALIPAY
|
||||
let options = my.getLaunchOptionsSync();
|
||||
options.query && Object.assign(data, options.query);
|
||||
// #endif
|
||||
|
||||
this.preview = data.preview || 0;
|
||||
this.isIphoneX = this.$util.uniappIsIPhoneX();
|
||||
|
||||
if (data.source_member) {
|
||||
uni.setStorageSync('source_member', data.source_member);
|
||||
this.source_member = data.source_member;
|
||||
}
|
||||
//记录分享关系
|
||||
if (this.storeToken && uni.getStorageSync('source_member')) {
|
||||
this.$util.onSourceMember(uni.getStorageSync('source_member'));
|
||||
}
|
||||
|
||||
// 小程序扫码进入
|
||||
if (data.scene) {
|
||||
var sceneParams = decodeURIComponent(data.scene);
|
||||
sceneParams = sceneParams.split('&');
|
||||
if (sceneParams.length) {
|
||||
sceneParams.forEach(item => {
|
||||
if (item.indexOf('m') != -1) uni.setStorageSync('source_member', item.split('-')[1]);
|
||||
if (item.indexOf('is_test') != -1) uni.setStorageSync('is_test', 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
},
|
||||
methods: {
|
||||
// 处理商品详情数据
|
||||
async handleGoodsSkuData() {
|
||||
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
|
||||
try {
|
||||
if (this.goodsSkuDetail.config) {
|
||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||
}
|
||||
|
||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||
|
||||
this.modifyGoodsInfo();
|
||||
|
||||
// 初始化商品详情视图数据
|
||||
try {
|
||||
if (this.$refs && this.$refs.goodsDetailView) {
|
||||
this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('goodsDetailView.init failed:', e);
|
||||
}
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
else this.goodsSkuDetail.sku_images = [];
|
||||
}
|
||||
|
||||
// 多规格时合并主图
|
||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||
|
||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||
}
|
||||
|
||||
let maxHeight = '';
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||
if (typeof item.pic_spec == "string")
|
||||
item.pic_spec = item.pic_spec.split('*');
|
||||
|
||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||
|
||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||
maxHeight = item.pic_spec[1];
|
||||
}
|
||||
});
|
||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||
|
||||
// 当前商品SKU规格
|
||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||
|
||||
// 商品属性
|
||||
if (this.goodsSkuDetail.goods_attr_format) {
|
||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品SKU格式
|
||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||
|
||||
// 商品详情
|
||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||
|
||||
//商品服务
|
||||
if (this.goodsSkuDetail.goods_service) {
|
||||
for (let i in this.goodsSkuDetail.goods_service) {
|
||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||
}
|
||||
}
|
||||
|
||||
this.contactData = {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
path: this.shareUrl,
|
||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
try {
|
||||
if (this.$refs && this.$refs.goodsPromotion) {
|
||||
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
}
|
||||
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
|
||||
|
||||
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
|
||||
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
|
||||
try {
|
||||
this._goodsFormPromise = this.getGoodsForm();
|
||||
} catch (e) {
|
||||
console.error('getGoodsForm kicked off failed:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('handleGoodsSkuData failed:', e);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 刷新商品详情数据
|
||||
* @param {Object} data
|
||||
*/
|
||||
refreshGoodsSkuDetail(data) {
|
||||
this.goodsSkuDetail = Object.assign({}, this.goodsSkuDetail, data);
|
||||
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
if (this.$refs.goodsDetailView) {
|
||||
|
||||
// 初始化商品详情视图数据
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
|
||||
|
||||
// 解决轮播图数量不一致时,切换到第一个
|
||||
if (this.swiperCurrent > this.goodsSkuDetail.sku_images.length) {
|
||||
this.swiperAutoplay = true;
|
||||
this.swiperCurrent = 1;
|
||||
setTimeout(() => {
|
||||
this.swiperAutoplay = false;
|
||||
}, 40);
|
||||
}
|
||||
|
||||
}
|
||||
this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
|
||||
this.$langConfig.title(this.navbarData.title);
|
||||
|
||||
if (this.goodsSkuDetail.membercard) {
|
||||
this.membercard = this.goodsSkuDetail.membercard;
|
||||
}
|
||||
},
|
||||
goodsDetailViewInit() {
|
||||
// 初始化商品详情视图数据
|
||||
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
},
|
||||
goHome() {
|
||||
if (this.preview) return; // 开启预览,禁止任何操作和跳转
|
||||
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);
|
||||
},
|
||||
goCart() {
|
||||
if (this.preview) return; // 开启预览,禁止任何操作和跳转
|
||||
this.$util.redirectTo('/pages_goods/cart');
|
||||
},
|
||||
//-------------------------------------关注-------------------------------------
|
||||
//更新商品信息
|
||||
modifyGoodsInfo() {
|
||||
if (this.preview) return; // 开启预览,禁止任何操作和跳转
|
||||
//更新商品点击量
|
||||
this.$api.sendRequest({
|
||||
url: "/api/goods/modifyclicks",
|
||||
data: {
|
||||
sku_id: this.skuId
|
||||
},
|
||||
success: res => {
|
||||
}
|
||||
});
|
||||
|
||||
//添加足迹
|
||||
this.$api.sendRequest({
|
||||
url: "/api/goodsbrowse/add",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
sku_id: this.skuId
|
||||
},
|
||||
success: res => {
|
||||
}
|
||||
});
|
||||
},
|
||||
//-------------------------------------关注-------------------------------------
|
||||
async editCollection() {
|
||||
if (this.$refs.goodsDetailView) {
|
||||
this.whetherCollection = await this.$refs.goodsDetailView.collection();
|
||||
}
|
||||
},
|
||||
openSharePopup() {
|
||||
if (this.$refs.goodsDetailView) {
|
||||
this.$refs.goodsDetailView.openSharePopup();
|
||||
}
|
||||
},
|
||||
//弹幕
|
||||
getBarrageData() {
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goods/goodsbarrage',
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
let barrageData = [];
|
||||
for (let i in res.data.list) {
|
||||
if (res.data.list[i]['title']) {
|
||||
let title = res.data.list[i]['title'].substr(0, 1) + '*' + res.data.list[i]['title'].substr(res.data.list[i]['title'].length - 1, 1)
|
||||
barrageData.push({
|
||||
img: res.data.list[i]['img'] ? res.data.list[i]['img'] : this.$util.getDefaultImage().head,
|
||||
title: title + '已下单'
|
||||
});
|
||||
}
|
||||
}
|
||||
this.goodsSkuDetail.barrageData = barrageData;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 设置公众号分享
|
||||
*/
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + this.shareUrl;
|
||||
if (this.memberInfo && this.memberInfo.member_id) shareUrl += '&source_member=' + this.memberInfo.member_id;
|
||||
|
||||
this.$util.setPublicShare({
|
||||
title: this.goodsSkuDetail.goods_name,
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: typeof this.goodsSkuDetail.goods_image == 'object' ? this.goodsSkuDetail.goods_image[0] : this.goodsSkuDetail.goods_image.split(',')[0]
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取商品表单
|
||||
* 后端 goodsform 接口与 info() 对齐,返回 {json_data, form_config(camelCase),
|
||||
* form_name, form_tip, form_banner, show_banner, show_tip}。
|
||||
* 全部字段都落到 goodsSkuDetail 上:表单字段 / 样式 / banner / tip 元数据,
|
||||
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
|
||||
*/
|
||||
async getGoodsForm() {
|
||||
// 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
|
||||
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
|
||||
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
|
||||
const emptyFn = () => {
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
||||
};
|
||||
emptyFn();
|
||||
return;
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await this.$api.sendRequest({
|
||||
url: "/form/api/form/goodsform",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
format: 'object'
|
||||
},
|
||||
async: false
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('getGoodsForm request failed', e);
|
||||
res = null;
|
||||
}
|
||||
const empty = () => {
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
|
||||
};
|
||||
if (!res || res.code != 0 || !res.data) {
|
||||
empty();
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(res.data)) {
|
||||
// 旧版扁平数组兼容
|
||||
empty();
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', res.data);
|
||||
return;
|
||||
}
|
||||
this.$set(this.goodsSkuDetail, 'goods_form', res.data.json_data || []);
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_config', res.data.form_config || {});
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_name', res.data.form_name || '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_tip', res.data.form_tip || '');
|
||||
this.$set(this.goodsSkuDetail, 'goods_form_banner', res.data.form_banner || '');
|
||||
// show_banner/show_tip 后端存 0/1,归一化 boolean
|
||||
this.$set(
|
||||
this.goodsSkuDetail,
|
||||
'goods_form_show_banner',
|
||||
res.data.show_banner == 1 ? true : (res.data.show_banner === 0 ? false : true)
|
||||
);
|
||||
this.$set(
|
||||
this.goodsSkuDetail,
|
||||
'goods_form_show_tip',
|
||||
res.data.show_tip == 1 ? true : (res.data.show_tip === 0 ? false : true)
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 自定义分享内容
|
||||
* @param {Object} res
|
||||
*/
|
||||
onShareAppMessage(res) {
|
||||
var path = this.shareUrl;
|
||||
if (this.memberInfo && this.memberInfo.member_id) path += '&source_member=' + this.memberInfo.member_id;
|
||||
return {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
imageUrl: this.shareImg ? this.$util.img(this.shareImg) : this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
}),
|
||||
path: path,
|
||||
success: res => {
|
||||
},
|
||||
fail: res => {
|
||||
}
|
||||
};
|
||||
},
|
||||
// 分享到微信朋友圈
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareTimeline() {
|
||||
let query = this.shareQuery;
|
||||
if (this.memberInfo && this.memberInfo.member_id) query += '&source_member=' + this.memberInfo.member_id;
|
||||
return {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
query: query,
|
||||
imageUrl: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
}
|
||||
// #endif
|
||||
// 商品详情业务
|
||||
import htmlParser from '@/common/js/html-parser';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
skuId: 0,
|
||||
goodsId: 0,
|
||||
// 商品详情
|
||||
goodsSkuDetail: {
|
||||
goods_id: 0,
|
||||
goods_service: []
|
||||
},
|
||||
preview: 0, //是否开启预览,0:不开启,1:开启
|
||||
//评价
|
||||
contactData: {
|
||||
title: '',
|
||||
path: '',
|
||||
img: ''
|
||||
},
|
||||
|
||||
shareQuery: '', // 分享参数
|
||||
shareUrl: '', // 分享链接
|
||||
|
||||
source_member: 0, //分享人的id
|
||||
chatRoomParams: {}, // 联系客服参数
|
||||
isIphoneX: false, //判断手机是否是iphoneX以上
|
||||
whetherCollection: 0,
|
||||
posterParams: {}, //海报所需参数
|
||||
shareImg: '',
|
||||
navbarData: {
|
||||
title: '',
|
||||
topNavColor: "#ffffff",
|
||||
topNavBg: false,
|
||||
navBarSwitch: true, // 导航栏是否显示
|
||||
textNavColor: "#333333",
|
||||
moreLink: {
|
||||
name: ""
|
||||
},
|
||||
navStyle: 1,
|
||||
bgUrl: '',
|
||||
textImgPosLink: 'left'
|
||||
},
|
||||
goodsFormVal: []
|
||||
}
|
||||
},
|
||||
onLoad(data) {
|
||||
//刷新多语言
|
||||
this.$langConfig.refresh();
|
||||
// #ifdef MP-ALIPAY
|
||||
let options = my.getLaunchOptionsSync();
|
||||
options.query && Object.assign(data, options.query);
|
||||
// #endif
|
||||
|
||||
this.preview = data.preview || 0;
|
||||
this.isIphoneX = this.$util.uniappIsIPhoneX();
|
||||
|
||||
if (data.source_member) {
|
||||
uni.setStorageSync('source_member', data.source_member);
|
||||
this.source_member = data.source_member;
|
||||
}
|
||||
//记录分享关系
|
||||
if (this.storeToken && uni.getStorageSync('source_member')) {
|
||||
this.$util.onSourceMember(uni.getStorageSync('source_member'));
|
||||
}
|
||||
|
||||
// 小程序扫码进入
|
||||
if (data.scene) {
|
||||
var sceneParams = decodeURIComponent(data.scene);
|
||||
sceneParams = sceneParams.split('&');
|
||||
if (sceneParams.length) {
|
||||
sceneParams.forEach(item => {
|
||||
if (item.indexOf('m') != -1) uni.setStorageSync('source_member', item.split('-')[1]);
|
||||
if (item.indexOf('is_test') != -1) uni.setStorageSync('is_test', 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
},
|
||||
methods: {
|
||||
detailChangeVal(data) {
|
||||
this.goodsFormVal = data;
|
||||
},
|
||||
// 处理商品详情数据
|
||||
handleGoodsSkuData() {
|
||||
this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
|
||||
this.$langConfig.title(this.navbarData.title);
|
||||
if (this.goodsSkuDetail.config) {
|
||||
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
|
||||
}
|
||||
|
||||
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
|
||||
|
||||
this.modifyGoodsInfo();
|
||||
|
||||
// 初始化商品详情视图数据
|
||||
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count,
|
||||
goods_class : this.goodsSkuDetail.goods_class,
|
||||
sale_store: this.goodsSkuDetail.sale_store
|
||||
});
|
||||
|
||||
//媒体
|
||||
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
|
||||
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
|
||||
else this.goodsSkuDetail.sku_images = [];
|
||||
}
|
||||
|
||||
// 多规格时合并主图
|
||||
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
|
||||
|
||||
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
|
||||
|
||||
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
|
||||
}
|
||||
|
||||
let maxHeight = '';
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
|
||||
if (typeof item.pic_spec == "string")
|
||||
item.pic_spec = item.pic_spec.split('*');
|
||||
|
||||
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
|
||||
item.pic_spec[0] = item.pic_spec[0] / ratio;
|
||||
item.pic_spec[1] = item.pic_spec[1] / ratio;
|
||||
|
||||
if (!maxHeight || maxHeight > item.pic_spec[1]) {
|
||||
maxHeight = item.pic_spec[1];
|
||||
}
|
||||
});
|
||||
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
|
||||
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || "件";
|
||||
|
||||
// 当前商品SKU规格
|
||||
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
|
||||
|
||||
// 商品属性
|
||||
if (this.goodsSkuDetail.goods_attr_format) {
|
||||
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
|
||||
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
|
||||
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
|
||||
for (var j = 0; j < goods_attr_format.length; j++) {
|
||||
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
|
||||
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品SKU格式
|
||||
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
|
||||
|
||||
// 商品详情
|
||||
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
|
||||
console.log(this.goodsSkuDetail.goods_content)
|
||||
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
|
||||
|
||||
//商品服务
|
||||
if (this.goodsSkuDetail.goods_service) {
|
||||
for (let i in this.goodsSkuDetail.goods_service) {
|
||||
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
|
||||
}
|
||||
}
|
||||
|
||||
this.contactData = {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
path: this.shareUrl,
|
||||
img: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
|
||||
this.setPublicShare();
|
||||
// if (this.goodsRoute != '/pages/goods/detail') this.setPublicShare();
|
||||
|
||||
this.getBarrageData();
|
||||
if (this.addonIsExist.form) {
|
||||
this.getGoodsForm();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 刷新商品详情数据
|
||||
* @param {Object} data
|
||||
*/
|
||||
refreshGoodsSkuDetail(data) {
|
||||
this.goodsSkuDetail = Object.assign({}, this.goodsSkuDetail, data);
|
||||
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
|
||||
if (this.$refs.goodsDetailView) {
|
||||
|
||||
// 初始化商品详情视图数据
|
||||
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || "件";
|
||||
|
||||
// 解决轮播图数量不一致时,切换到第一个
|
||||
if (this.swiperCurrent > this.goodsSkuDetail.sku_images.length) {
|
||||
this.swiperAutoplay = true;
|
||||
this.swiperCurrent = 1;
|
||||
setTimeout(() => {
|
||||
this.swiperAutoplay = false;
|
||||
}, 40);
|
||||
}
|
||||
|
||||
}
|
||||
this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
|
||||
this.$langConfig.title(this.navbarData.title);
|
||||
|
||||
if (this.goodsSkuDetail.membercard) {
|
||||
this.membercard = this.goodsSkuDetail.membercard;
|
||||
}
|
||||
},
|
||||
goodsDetailViewInit() {
|
||||
// 初始化商品详情视图数据
|
||||
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
|
||||
sku_id: this.skuId,
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
preview: this.preview,
|
||||
source_member: this.source_member,
|
||||
posterParams: this.posterParams,
|
||||
posterApi: this.posterApi,
|
||||
shareUrl: this.shareUrl,
|
||||
goodsRoute: this.goodsRoute,
|
||||
isVirtual: this.goodsSkuDetail.is_virtual,
|
||||
deliveryType: this.goodsSkuDetail.express_type,
|
||||
whetherCollection: this.goodsSkuDetail.is_collect,
|
||||
evaluateConfig: this.goodsSkuDetail.evaluate_config,
|
||||
evaluateList: this.goodsSkuDetail.evaluate_list,
|
||||
evaluateCount: this.goodsSkuDetail.evaluate_count
|
||||
});
|
||||
},
|
||||
goHome() {
|
||||
if (this.preview) return; // 开启预览,禁止任何操作和跳转
|
||||
this.$util.redirectTo('/pages/index/index');
|
||||
},
|
||||
goCart() {
|
||||
if (this.preview) return; // 开启预览,禁止任何操作和跳转
|
||||
this.$util.redirectTo('/pages/goods/cart');
|
||||
},
|
||||
//-------------------------------------关注-------------------------------------
|
||||
//更新商品信息
|
||||
modifyGoodsInfo() {
|
||||
if (this.preview) return; // 开启预览,禁止任何操作和跳转
|
||||
//更新商品点击量
|
||||
this.$api.sendRequest({
|
||||
url: "/api/goods/modifyclicks",
|
||||
data: {
|
||||
sku_id: this.skuId
|
||||
},
|
||||
success: res => {
|
||||
}
|
||||
});
|
||||
|
||||
//添加足迹
|
||||
this.$api.sendRequest({
|
||||
url: "/api/goodsbrowse/add",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id,
|
||||
sku_id: this.skuId
|
||||
},
|
||||
success: res => {
|
||||
}
|
||||
});
|
||||
},
|
||||
//-------------------------------------关注-------------------------------------
|
||||
async editCollection() {
|
||||
if (this.$refs.goodsDetailView) {
|
||||
this.whetherCollection = await this.$refs.goodsDetailView.collection();
|
||||
}
|
||||
},
|
||||
openSharePopup() {
|
||||
if (this.$refs.goodsDetailView) {
|
||||
this.$refs.goodsDetailView.openSharePopup();
|
||||
}
|
||||
},
|
||||
//弹幕
|
||||
getBarrageData() {
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goods/goodsbarrage',
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
let barrageData = [];
|
||||
for (let i in res.data.list) {
|
||||
if (res.data.list[i]['title']) {
|
||||
let title = res.data.list[i]['title'].substr(0, 1) + '*' + res.data.list[i]['title'].substr(res.data.list[i]['title'].length - 1, 1)
|
||||
barrageData.push({
|
||||
img: res.data.list[i]['img'] ? res.data.list[i]['img'] : this.$util.getDefaultImage().head,
|
||||
title: title + '已下单'
|
||||
});
|
||||
}
|
||||
}
|
||||
this.goodsSkuDetail.barrageData = barrageData;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 设置公众号分享
|
||||
*/
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + this.shareUrl;
|
||||
if (this.memberInfo && this.memberInfo.member_id) shareUrl += '&source_member=' + this.memberInfo.member_id;
|
||||
var store_info = this.$store.state.globalStoreInfo;
|
||||
if (store_info) shareUrl+= '&store_id=' + store_info.store_id;
|
||||
this.$util.setPublicShare({
|
||||
title: this.goodsSkuDetail.goods_name,
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: typeof this.goodsSkuDetail.goods_image == 'object' ? this.goodsSkuDetail.goods_image[0] : this.goodsSkuDetail.goods_image.split(',')[0]
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 获取商品表单
|
||||
*/
|
||||
getGoodsForm() {
|
||||
this.$api.sendRequest({
|
||||
url: "/form/api/form/goodsform",
|
||||
data: {
|
||||
goods_id: this.goodsSkuDetail.goods_id
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) this.$set(this.goodsSkuDetail, 'goods_form', res.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 自定义分享内容
|
||||
* @param {Object} res
|
||||
*/
|
||||
onShareAppMessage(res) {
|
||||
var path = this.shareUrl;
|
||||
var store_info = this.$store.state.globalStoreInfo;
|
||||
if (store_info) path+= '&store_id=' + store_info.store_id;
|
||||
if (this.memberInfo && this.memberInfo.member_id) path += '&source_member=' + this.memberInfo.member_id;
|
||||
return {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
imageUrl: this.shareImg ? this.$util.img(this.shareImg) : this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
}),
|
||||
path: path,
|
||||
success: res => {
|
||||
},
|
||||
fail: res => {
|
||||
}
|
||||
};
|
||||
},
|
||||
// 分享到微信朋友圈
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareTimeline() {
|
||||
let query = this.shareQuery;
|
||||
var store_info = this.$store.state.globalStoreInfo;
|
||||
if (store_info) query+= '&store_id=' + store_info.store_id;
|
||||
if (this.memberInfo && this.memberInfo.member_id) query += '&source_member=' + this.memberInfo.member_id;
|
||||
return {
|
||||
title: this.goodsSkuDetail.sku_name,
|
||||
query: query,
|
||||
imageUrl: this.$util.img(this.goodsSkuDetail.sku_image, {
|
||||
size: 'big'
|
||||
})
|
||||
};
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
@@ -1,438 +1,440 @@
|
||||
import util from './util.js'
|
||||
/*
|
||||
* HTML5 Parser By Sam Blowes
|
||||
*
|
||||
* Designed for HTML5 documents
|
||||
*
|
||||
* Original code by John Resig (ejohn.org)
|
||||
* http://ejohn.org/blog/pure-javascript-html-parser/
|
||||
* Original code by Erik Arvidsson, Mozilla Public License
|
||||
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
|
||||
*
|
||||
* ----------------------------------------------------------------------------
|
||||
* License
|
||||
* ----------------------------------------------------------------------------
|
||||
*
|
||||
* This code is triple licensed using Apache Software License 2.0,
|
||||
* Mozilla Public License or GNU Public License
|
||||
*
|
||||
* ////////////////////////////////////////////////////////////////////////////
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||
* use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* ////////////////////////////////////////////////////////////////////////////
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License
|
||||
* Version 1.1 (the "License"); you may not use this file except in
|
||||
* compliance with the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS"
|
||||
* basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing rights and limitations
|
||||
* under the License.
|
||||
*
|
||||
* The Original Code is Simple HTML Parser.
|
||||
*
|
||||
* The Initial Developer of the Original Code is Erik Arvidsson.
|
||||
* Portions created by Erik Arvidssson are Copyright (C) 2004. All Rights
|
||||
* Reserved.
|
||||
*
|
||||
* ////////////////////////////////////////////////////////////////////////////
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* ----------------------------------------------------------------------------
|
||||
* Usage
|
||||
* ----------------------------------------------------------------------------
|
||||
*
|
||||
* // Use like so:
|
||||
* HTMLParser(htmlString, {
|
||||
* start: function(tag, attrs, unary) {},
|
||||
* end: function(tag) {},
|
||||
* chars: function(text) {},
|
||||
* comment: function(text) {}
|
||||
* });
|
||||
*
|
||||
* // or to get an XML string:
|
||||
* HTMLtoXML(htmlString);
|
||||
*
|
||||
* // or to get an XML DOM Document
|
||||
* HTMLtoDOM(htmlString);
|
||||
*
|
||||
* // or to inject into an existing document/DOM node
|
||||
* HTMLtoDOM(htmlString, document);
|
||||
* HTMLtoDOM(htmlString, document.body);
|
||||
*
|
||||
*/
|
||||
// Regular Expressions for parsing tags and attributes
|
||||
var startTag =
|
||||
/^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/;
|
||||
var endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/;
|
||||
var attr =
|
||||
/([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; // Empty Elements - HTML 5
|
||||
|
||||
var empty = makeMap(
|
||||
'area,base,basefont,br,col,frame,hr,img,input,link,meta,param,embed,command,keygen,source,track,wbr'
|
||||
); // Block Elements - HTML 5
|
||||
// fixed by xxx 将 ins 标签从块级名单中移除
|
||||
|
||||
var block = makeMap(
|
||||
'a,address,article,applet,aside,audio,blockquote,button,canvas,center,dd,del,dir,div,dl,dt,fieldset,figcaption,figure,footer,form,frameset,h1,h2,h3,h4,h5,h6,header,hgroup,hr,iframe,isindex,li,map,menu,noframes,noscript,object,ol,output,p,pre,section,script,table,tbody,td,tfoot,th,thead,tr,ul,video'
|
||||
); // Inline Elements - HTML 5
|
||||
|
||||
var inline = makeMap(
|
||||
'abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,textarea,tt,u,var'
|
||||
); // Elements that you can, intentionally, leave open
|
||||
// (and which close themselves)
|
||||
|
||||
var closeSelf = makeMap(
|
||||
'colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr'); // Attributes that have their values filled in disabled="disabled"
|
||||
|
||||
var fillAttrs = makeMap(
|
||||
'checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected'
|
||||
); // Special Elements (can contain anything)
|
||||
|
||||
var special = makeMap('script,style');
|
||||
|
||||
function HTMLParser(html, handler) {
|
||||
var index;
|
||||
var chars;
|
||||
var match;
|
||||
var stack = [];
|
||||
var last = html;
|
||||
|
||||
stack.last = function() {
|
||||
return this[this.length - 1];
|
||||
};
|
||||
|
||||
while (html) {
|
||||
chars = true; // Make sure we're not in a script or style element
|
||||
if (!stack.last() || !special[stack.last()]) {
|
||||
// Comment
|
||||
if (html.indexOf('<!--') == 0) {
|
||||
index = html.indexOf('-->');
|
||||
|
||||
if (index >= 0) {
|
||||
if (handler.comment) {
|
||||
handler.comment(html.substring(4, index));
|
||||
}
|
||||
|
||||
html = html.substring(index + 3);
|
||||
chars = false;
|
||||
} // end tag
|
||||
|
||||
} else if (html.indexOf('</') == 0) {
|
||||
match = html.match(endTag);
|
||||
|
||||
if (match) {
|
||||
html = html.substring(match[0].length);
|
||||
match[0].replace(endTag, parseEndTag);
|
||||
chars = false;
|
||||
} // start tag
|
||||
|
||||
} else if (html.indexOf('<') == 0) {
|
||||
match = html.match(startTag);
|
||||
|
||||
if (match) {
|
||||
html = html.substring(match[0].length);
|
||||
match[0].replace(startTag, parseStartTag);
|
||||
chars = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (chars) {
|
||||
index = html.indexOf('<');
|
||||
var text = index < 0 ? html : html.substring(0, index);
|
||||
html = index < 0 ? '' : html.substring(index);
|
||||
|
||||
if (handler.chars) {
|
||||
handler.chars(text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html = html.replace(new RegExp('([\\s\\S]*?)<\/' + stack.last() + '[^>]*>'), function(all, text) {
|
||||
text = text.replace(/<!--([\s\S]*?)-->|<!\[CDATA\[([\s\S]*?)]]>/g, '$1$2');
|
||||
|
||||
if (handler.chars) {
|
||||
handler.chars(text);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
parseEndTag('', stack.last());
|
||||
}
|
||||
|
||||
|
||||
if (html == last) {
|
||||
throw 'Parse Error: ' + html;
|
||||
}
|
||||
|
||||
last = html;
|
||||
} // Clean up any remaining tags
|
||||
|
||||
|
||||
parseEndTag();
|
||||
|
||||
function parseStartTag(tag, tagName, rest, unary) {
|
||||
tagName = tagName.toLowerCase();
|
||||
|
||||
if (block[tagName]) {
|
||||
while (stack.last() && inline[stack.last()]) {
|
||||
parseEndTag('', stack.last());
|
||||
}
|
||||
}
|
||||
|
||||
if (closeSelf[tagName] && stack.last() == tagName) {
|
||||
parseEndTag('', tagName);
|
||||
}
|
||||
|
||||
unary = empty[tagName] || !!unary;
|
||||
|
||||
if (!unary) {
|
||||
stack.push(tagName);
|
||||
}
|
||||
|
||||
if (handler.start) {
|
||||
var attrs = [];
|
||||
rest.replace(attr, function(match, name) {
|
||||
var value = arguments[2] ? arguments[2] : arguments[3] ? arguments[3] : arguments[4] ?
|
||||
arguments[4] : fillAttrs[
|
||||
name] ? name : '';
|
||||
attrs.push({
|
||||
name: name,
|
||||
value: value,
|
||||
escaped: value.replace(/(^|[^\\])"/g, '$1\\\"') // "
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
if (handler.start) {
|
||||
handler.start(tagName, attrs, unary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseEndTag(tag, tagName) {
|
||||
// If no tag name is provided, clean shop
|
||||
if (!tagName) {
|
||||
var pos = 0;
|
||||
} // Find the closest opened tag of the same type
|
||||
else {
|
||||
for (var pos = stack.length - 1; pos >= 0; pos--) {
|
||||
if (stack[pos] == tagName) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pos >= 0) {
|
||||
// Close all the open elements, up the stack
|
||||
for (var i = stack.length - 1; i >= pos; i--) {
|
||||
if (handler.end) {
|
||||
handler.end(stack[i]);
|
||||
}
|
||||
} // Remove the open elements from the stack
|
||||
|
||||
|
||||
stack.length = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeMap(str) {
|
||||
var obj = {};
|
||||
var items = str.split(',');
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
obj[items[i]] = true;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function removeDOCTYPE(html) {
|
||||
return html.replace(/<\?xml.*\?>\n/, '').replace(/<!doctype.*>\n/, '').replace(/<!DOCTYPE.*>\n/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略注释
|
||||
* @param {Object} html
|
||||
*/
|
||||
function replaceAnnotation(html) {
|
||||
var html = html.replace(/<!--[\s\S]*-->/gi, '');
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换图片
|
||||
* @param {Object} html
|
||||
*/
|
||||
function replaceImage(html) {
|
||||
|
||||
// #ifdef MP
|
||||
let info = uni.getSystemInfoSync();
|
||||
var screenWidth = info.windowWidth;
|
||||
screenWidth -= 20;
|
||||
screenWidth += 'px';
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
var screenWidth = '100%';
|
||||
// #endif
|
||||
|
||||
let rep = `<img style="width:100% !important;display:block;max-width: ${screenWidth} !important;"`;
|
||||
var html = html.replace(/\\/g, '').replace(/<img/g, rep);
|
||||
html = html.replace(/<img [^>]*src=['"]([^'"]+)[^>]*>/gi, (match, capture) => {
|
||||
return rep + ' src="' + util.img(capture) + '"/>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
function replaceVideo(html){
|
||||
// #ifdef MP
|
||||
let info = uni.getSystemInfoSync();
|
||||
var screenWidth = info.windowWidth;
|
||||
screenWidth -= 20;
|
||||
screenWidth += 'px';
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
var screenWidth = '100%';
|
||||
// #endif
|
||||
|
||||
let rep = `<video style="width:100% !important;display:block;max-width: ${screenWidth} !important;"`;
|
||||
var html = html.replace(/\\/g, '').replace(/<video/g, rep);
|
||||
html = html.replace(/<video [^>]*src=['"]([^'"]+)[^>]*>/gi, (match, capture) => {
|
||||
return rep + ' src="' + util.img(capture) + '"/>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将style属性中的双引号改为单引号
|
||||
* @param {Object} html
|
||||
*/
|
||||
function replaceStyleQuotes(html) {
|
||||
var html = html.replace(/style\s*=\s*["][^>]*;[^"]?/gi, (match, capture) => {
|
||||
match = match.replace(/[:](\s?)[\s\S]*/gi, (a, b) => {
|
||||
return a.replace(/"/g, "'");
|
||||
});
|
||||
return match;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseAttrs(attrs) {
|
||||
return attrs.reduce(function(pre, attr) {
|
||||
var value = attr.value;
|
||||
var name = attr.name;
|
||||
|
||||
if (pre[name]) {
|
||||
pre[name] = pre[name] + " " + value;
|
||||
} else {
|
||||
pre[name] = value;
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function parseHtml(html) {
|
||||
html = removeDOCTYPE(html);
|
||||
html = replaceAnnotation(html); //忽略注释
|
||||
html = replaceImage(html); //替换图片
|
||||
html = replaceStyleQuotes(html); //将style属性中的双引号改为单引号
|
||||
|
||||
html = replaceVideo(html); //替换视频链接
|
||||
var stacks = [];
|
||||
var results = {
|
||||
node: 'root',
|
||||
children: []
|
||||
};
|
||||
HTMLParser(html, {
|
||||
start: function start(tag, attrs, unary) {
|
||||
var node = {
|
||||
name: tag
|
||||
};
|
||||
|
||||
if (attrs.length !== 0) {
|
||||
node.attrs = parseAttrs(attrs);
|
||||
}
|
||||
|
||||
if (unary) {
|
||||
var parent = stacks[0] || results;
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
stacks.unshift(node);
|
||||
}
|
||||
},
|
||||
end: function end(tag) {
|
||||
var node = stacks.shift();
|
||||
if (node.name !== tag) console.error('invalid state: mismatch end tag');
|
||||
if (stacks.length === 0) {
|
||||
results.children.push(node);
|
||||
} else {
|
||||
var parent = stacks[0];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
},
|
||||
chars: function chars(text) {
|
||||
var node = {
|
||||
type: 'text',
|
||||
text: text
|
||||
};
|
||||
|
||||
if (stacks.length === 0) {
|
||||
results.children.push(node);
|
||||
} else {
|
||||
var parent = stacks[0];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
},
|
||||
comment: function comment(text) {
|
||||
var node = {
|
||||
node: 'comment',
|
||||
text: text
|
||||
};
|
||||
var parent = stacks[0];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
});
|
||||
return results.children;
|
||||
}
|
||||
|
||||
import util from './util.js'
|
||||
/*
|
||||
* HTML5 Parser By Sam Blowes
|
||||
*
|
||||
* Designed for HTML5 documents
|
||||
*
|
||||
* Original code by John Resig (ejohn.org)
|
||||
* http://ejohn.org/blog/pure-javascript-html-parser/
|
||||
* Original code by Erik Arvidsson, Mozilla Public License
|
||||
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
|
||||
*
|
||||
* ----------------------------------------------------------------------------
|
||||
* License
|
||||
* ----------------------------------------------------------------------------
|
||||
*
|
||||
* This code is triple licensed using Apache Software License 2.0,
|
||||
* Mozilla Public License or GNU Public License
|
||||
*
|
||||
* ////////////////////////////////////////////////////////////////////////////
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||
* use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* ////////////////////////////////////////////////////////////////////////////
|
||||
*
|
||||
* The contents of this file are subject to the Mozilla Public License
|
||||
* Version 1.1 (the "License"); you may not use this file except in
|
||||
* compliance with the License. You may obtain a copy of the License at
|
||||
* http://www.mozilla.org/MPL/
|
||||
*
|
||||
* Software distributed under the License is distributed on an "AS IS"
|
||||
* basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing rights and limitations
|
||||
* under the License.
|
||||
*
|
||||
* The Original Code is Simple HTML Parser.
|
||||
*
|
||||
* The Initial Developer of the Original Code is Erik Arvidsson.
|
||||
* Portions created by Erik Arvidssson are Copyright (C) 2004. All Rights
|
||||
* Reserved.
|
||||
*
|
||||
* ////////////////////////////////////////////////////////////////////////////
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* ----------------------------------------------------------------------------
|
||||
* Usage
|
||||
* ----------------------------------------------------------------------------
|
||||
*
|
||||
* // Use like so:
|
||||
* HTMLParser(htmlString, {
|
||||
* start: function(tag, attrs, unary) {},
|
||||
* end: function(tag) {},
|
||||
* chars: function(text) {},
|
||||
* comment: function(text) {}
|
||||
* });
|
||||
*
|
||||
* // or to get an XML string:
|
||||
* HTMLtoXML(htmlString);
|
||||
*
|
||||
* // or to get an XML DOM Document
|
||||
* HTMLtoDOM(htmlString);
|
||||
*
|
||||
* // or to inject into an existing document/DOM node
|
||||
* HTMLtoDOM(htmlString, document);
|
||||
* HTMLtoDOM(htmlString, document.body);
|
||||
*
|
||||
*/
|
||||
// Regular Expressions for parsing tags and attributes
|
||||
var startTag =
|
||||
/^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/;
|
||||
var endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/;
|
||||
var attr =
|
||||
/([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; // Empty Elements - HTML 5
|
||||
|
||||
var empty = makeMap(
|
||||
'area,base,basefont,br,col,frame,hr,img,input,link,meta,param,embed,command,keygen,source,track,wbr'
|
||||
); // Block Elements - HTML 5
|
||||
// fixed by xxx 将 ins 标签从块级名单中移除
|
||||
|
||||
var block = makeMap(
|
||||
'a,address,article,applet,aside,audio,blockquote,button,canvas,center,dd,del,dir,div,dl,dt,fieldset,figcaption,figure,footer,form,frameset,h1,h2,h3,h4,h5,h6,header,hgroup,hr,iframe,isindex,li,map,menu,noframes,noscript,object,ol,output,p,pre,section,script,table,tbody,td,tfoot,th,thead,tr,ul,video'
|
||||
); // Inline Elements - HTML 5
|
||||
|
||||
var inline = makeMap(
|
||||
'abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,textarea,tt,u,var'
|
||||
); // Elements that you can, intentionally, leave open
|
||||
// (and which close themselves)
|
||||
|
||||
var closeSelf = makeMap(
|
||||
'colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr'); // Attributes that have their values filled in disabled="disabled"
|
||||
|
||||
var fillAttrs = makeMap(
|
||||
'checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected'
|
||||
); // Special Elements (can contain anything)
|
||||
|
||||
var special = makeMap('script,style');
|
||||
|
||||
function HTMLParser(html, handler) {
|
||||
var index;
|
||||
var chars;
|
||||
var match;
|
||||
var stack = [];
|
||||
var last = html;
|
||||
|
||||
stack.last = function() {
|
||||
return this[this.length - 1];
|
||||
};
|
||||
|
||||
while (html) {
|
||||
chars = true; // Make sure we're not in a script or style element
|
||||
if (!stack.last() || !special[stack.last()]) {
|
||||
// Comment
|
||||
if (html.indexOf('<!--') == 0) {
|
||||
index = html.indexOf('-->');
|
||||
|
||||
if (index >= 0) {
|
||||
if (handler.comment) {
|
||||
handler.comment(html.substring(4, index));
|
||||
}
|
||||
|
||||
html = html.substring(index + 3);
|
||||
chars = false;
|
||||
} // end tag
|
||||
|
||||
} else if (html.indexOf('</') == 0) {
|
||||
match = html.match(endTag);
|
||||
|
||||
if (match) {
|
||||
html = html.substring(match[0].length);
|
||||
match[0].replace(endTag, parseEndTag);
|
||||
chars = false;
|
||||
} // start tag
|
||||
|
||||
} else if (html.indexOf('<') == 0) {
|
||||
match = html.match(startTag);
|
||||
|
||||
if (match) {
|
||||
html = html.substring(match[0].length);
|
||||
match[0].replace(startTag, parseStartTag);
|
||||
chars = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (chars) {
|
||||
index = html.indexOf('<');
|
||||
var text = index < 0 ? html : html.substring(0, index);
|
||||
html = index < 0 ? '' : html.substring(index);
|
||||
|
||||
if (handler.chars) {
|
||||
handler.chars(text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html = html.replace(new RegExp('([\\s\\S]*?)<\/' + stack.last() + '[^>]*>'), function(all, text) {
|
||||
text = text.replace(/<!--([\s\S]*?)-->|<!\[CDATA\[([\s\S]*?)]]>/g, '$1$2');
|
||||
|
||||
if (handler.chars) {
|
||||
handler.chars(text);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
parseEndTag('', stack.last());
|
||||
}
|
||||
|
||||
|
||||
if (html == last) {
|
||||
throw 'Parse Error: ' + html;
|
||||
}
|
||||
|
||||
last = html;
|
||||
} // Clean up any remaining tags
|
||||
|
||||
|
||||
parseEndTag();
|
||||
|
||||
function parseStartTag(tag, tagName, rest, unary) {
|
||||
tagName = tagName.toLowerCase();
|
||||
|
||||
if (block[tagName]) {
|
||||
while (stack.last() && inline[stack.last()]) {
|
||||
parseEndTag('', stack.last());
|
||||
}
|
||||
}
|
||||
|
||||
if (closeSelf[tagName] && stack.last() == tagName) {
|
||||
parseEndTag('', tagName);
|
||||
}
|
||||
|
||||
unary = empty[tagName] || !!unary;
|
||||
|
||||
if (!unary) {
|
||||
stack.push(tagName);
|
||||
}
|
||||
|
||||
if (handler.start) {
|
||||
var attrs = [];
|
||||
rest.replace(attr, function(match, name) {
|
||||
var value = arguments[2] ? arguments[2] : arguments[3] ? arguments[3] : arguments[4] ?
|
||||
arguments[4] : fillAttrs[
|
||||
name] ? name : '';
|
||||
attrs.push({
|
||||
name: name,
|
||||
value: value,
|
||||
escaped: value.replace(/(^|[^\\])"/g, '$1\\\"') // "
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
if (handler.start) {
|
||||
handler.start(tagName, attrs, unary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseEndTag(tag, tagName) {
|
||||
// If no tag name is provided, clean shop
|
||||
if (!tagName) {
|
||||
var pos = 0;
|
||||
} // Find the closest opened tag of the same type
|
||||
else {
|
||||
for (var pos = stack.length - 1; pos >= 0; pos--) {
|
||||
if (stack[pos] == tagName) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pos >= 0) {
|
||||
// Close all the open elements, up the stack
|
||||
for (var i = stack.length - 1; i >= pos; i--) {
|
||||
if (handler.end) {
|
||||
handler.end(stack[i]);
|
||||
}
|
||||
} // Remove the open elements from the stack
|
||||
|
||||
|
||||
stack.length = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeMap(str) {
|
||||
var obj = {};
|
||||
var items = str.split(',');
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
obj[items[i]] = true;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function removeDOCTYPE(html) {
|
||||
return html.replace(/<\?xml.*\?>\n/, '').replace(/<!doctype.*>\n/, '').replace(/<!DOCTYPE.*>\n/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略注释
|
||||
* @param {Object} html
|
||||
*/
|
||||
function replaceAnnotation(html) {
|
||||
var html = html.replace(/<!--[\s\S]*-->/gi, '');
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换图片
|
||||
* @param {Object} html
|
||||
*/
|
||||
function replaceImage(html) {
|
||||
|
||||
// #ifdef MP
|
||||
let info = uni.getWindowInfo();
|
||||
var screenWidth = info.windowWidth;
|
||||
screenWidth -= 20;
|
||||
screenWidth += 'px';
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
var screenWidth = '100%';
|
||||
// #endif
|
||||
|
||||
let rep = `<img style="width:100% !important;display:block;max-width: ${screenWidth} !important;"`;
|
||||
var html = html.replace(/\\/g, '').replace(/<img/g, rep);
|
||||
html = html.replace(/<img [^>]*src=['"]([^'"]+)[^>]*>/gi, (match, capture) => {
|
||||
return rep + ' src="' + util.img(capture) + '"/>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
function replaceVideo(html){
|
||||
// #ifdef MP
|
||||
let info = uni.getWindowInfo();
|
||||
var screenWidth = info.windowWidth;
|
||||
screenWidth -= 20;
|
||||
screenWidth += 'px';
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
var screenWidth = '100%';
|
||||
// #endif
|
||||
|
||||
let rep = `<video style="width:100% !important;display:block;max-width: ${screenWidth} !important;"`;
|
||||
var html = html.replace(/\\/g, '').replace(/<video/g, rep);
|
||||
console.log(html)
|
||||
html = html.replace(/<video [^>]*src=['"]([^'"]+)[^>]*>/gi, (match, capture) => {
|
||||
return rep + ' src="' + util.img(capture) + '"/>';
|
||||
});
|
||||
// console.log(html)
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将style属性中的双引号改为单引号
|
||||
* @param {Object} html
|
||||
*/
|
||||
function replaceStyleQuotes(html) {
|
||||
var html = html.replace(/style\s*=\s*["][^>]*;[^"]?/gi, (match, capture) => {
|
||||
match = match.replace(/[:](\s?)[\s\S]*/gi, (a, b) => {
|
||||
return a.replace(/"/g, "'");
|
||||
});
|
||||
return match;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseAttrs(attrs) {
|
||||
return attrs.reduce(function(pre, attr) {
|
||||
var value = attr.value;
|
||||
var name = attr.name;
|
||||
|
||||
if (pre[name]) {
|
||||
pre[name] = pre[name] + " " + value;
|
||||
} else {
|
||||
pre[name] = value;
|
||||
}
|
||||
|
||||
return pre;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function parseHtml(html) {
|
||||
html = removeDOCTYPE(html);
|
||||
html = replaceAnnotation(html); //忽略注释
|
||||
html = replaceImage(html); //替换图片
|
||||
html = replaceStyleQuotes(html); //将style属性中的双引号改为单引号
|
||||
|
||||
html = replaceVideo(html); //替换视频链接
|
||||
var stacks = [];
|
||||
var results = {
|
||||
node: 'root',
|
||||
children: []
|
||||
};
|
||||
HTMLParser(html, {
|
||||
start: function start(tag, attrs, unary) {
|
||||
var node = {
|
||||
name: tag
|
||||
};
|
||||
|
||||
if (attrs.length !== 0) {
|
||||
node.attrs = parseAttrs(attrs);
|
||||
}
|
||||
|
||||
if (unary) {
|
||||
var parent = stacks[0] || results;
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
stacks.unshift(node);
|
||||
}
|
||||
},
|
||||
end: function end(tag) {
|
||||
var node = stacks.shift();
|
||||
if (node.name !== tag) console.error('invalid state: mismatch end tag');
|
||||
if (stacks.length === 0) {
|
||||
results.children.push(node);
|
||||
} else {
|
||||
var parent = stacks[0];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
},
|
||||
chars: function chars(text) {
|
||||
var node = {
|
||||
type: 'text',
|
||||
text: text
|
||||
};
|
||||
|
||||
if (stacks.length === 0) {
|
||||
results.children.push(node);
|
||||
} else {
|
||||
var parent = stacks[0];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
},
|
||||
comment: function comment(text) {
|
||||
var node = {
|
||||
node: 'comment',
|
||||
text: text
|
||||
};
|
||||
var parent = stacks[0];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
});
|
||||
return results.children;
|
||||
}
|
||||
|
||||
export default parseHtml;
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 通用网络/接口错误分类器
|
||||
*
|
||||
* 适用场景:
|
||||
* - uniapp 各端统一错误分类(diy.js / 商品 / 订单 / 自定义页面等)
|
||||
* - 任意传入 {statusCode, errMsg, message} 形式错误对象的位置
|
||||
*
|
||||
* 设计要点:
|
||||
* 1) 与 Vue / $lang 解耦:不调用任何 i18n 函数,只返回分类结果 + 原始错误字段
|
||||
* 调用方拿到 type 后,自行决定使用 $lang('http.timeout') / $lang('http.serverError')
|
||||
* 或其它命名空间的文案,完全保持各模块原有的 key 命名习惯
|
||||
* 2) 通用网络/接口错误统一映射到 common.http.* 命名空间:
|
||||
* - http.networkError: 通用网络异常(兜底)
|
||||
* - http.timeout: 请求超时
|
||||
* - http.serverError: 服务器错误(5xx)
|
||||
* - http.notFound: 接口不存在(404)
|
||||
* - http.auth: 无访问权限(401/403)
|
||||
* - http.unknown: 其他非 2xx
|
||||
* 3) 兼容多种错误对象形态:
|
||||
* - uniapp request: { statusCode, errMsg, data }
|
||||
* - axios: { response: { status }, message }
|
||||
* - fetch / 原生: { message, name }
|
||||
*
|
||||
* @param {Object|string} err 错误对象或后端业务消息字符串
|
||||
* @returns {{ type: string, statusCode?: number, errMsg?: string, raw?: any }}
|
||||
* - type 取值:
|
||||
* 'business' - 后端返回的业务消息字符串(已透传到 raw)
|
||||
* 'empty' - 错误对象为空
|
||||
* 'notFound' - HTTP 404
|
||||
* 'server' - HTTP 5xx
|
||||
* 'auth' - HTTP 401 / 403
|
||||
* 'request' - 其它非 2xx 状态码
|
||||
* 'timeout' - errMsg 含 timeout
|
||||
* 'network' - errMsg 含 connect / network / fail
|
||||
* 'unknown' - 无法分类
|
||||
* - statusCode: 若有则原样返回
|
||||
* - errMsg: 归一后的原始消息
|
||||
* - raw: 原始输入,方便调用方进一步处理
|
||||
*/
|
||||
export function classifyHttpError(err) {
|
||||
// 1) 无错误对象
|
||||
if (!err) {
|
||||
return { type: 'empty', raw: err };
|
||||
}
|
||||
|
||||
// 2) 后端直接返回的业务消息(字符串)
|
||||
if (typeof err === 'string') {
|
||||
return { type: 'business', raw: err };
|
||||
}
|
||||
|
||||
// 3) 字段归一(兼容 uni / axios / 原生 Error)
|
||||
const statusCode = err.statusCode
|
||||
?? err.response?.status
|
||||
?? err.status;
|
||||
const errMsg = err.errMsg || err.message || '';
|
||||
|
||||
if (statusCode) {
|
||||
if (statusCode === 404) return { type: 'notFound', statusCode, errMsg, raw: err };
|
||||
if (statusCode >= 500) return { type: 'server', statusCode, errMsg, raw: err };
|
||||
if (statusCode === 401 || statusCode === 403) return { type: 'auth', statusCode, errMsg, raw: err };
|
||||
return { type: 'request', statusCode, errMsg, raw: err };
|
||||
}
|
||||
|
||||
if (/timeout/i.test(errMsg)) {
|
||||
return { type: 'timeout', errMsg, raw: err };
|
||||
}
|
||||
if (/(connect|network|fail)/i.test(errMsg)) {
|
||||
return { type: 'network', errMsg, raw: err };
|
||||
}
|
||||
|
||||
return { type: 'unknown', errMsg, raw: err };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 classifyHttpError 的结果,映射到 common.http.* 命名空间的 i18n key
|
||||
*
|
||||
* 通用网络/接口错误统一走 http.*,调用方通过 $lang(key) 解析文案。
|
||||
* 原 diy.errTimeout 这类 key 已合并到 http.*,保持项目国际化更专业。
|
||||
*
|
||||
* @param {string} type classifyHttpError 返回的 type
|
||||
* @returns {string|null} 对应的 http.* key;type='business' 时返回 null(透传 raw)
|
||||
*/
|
||||
export function httpErrorKey(type) {
|
||||
switch (type) {
|
||||
case 'empty': return 'http.networkError';
|
||||
case 'business': return null; // 透传,不用 key
|
||||
case 'notFound': return 'http.notFound';
|
||||
case 'server': return 'http.serverError';
|
||||
case 'auth': return 'http.auth';
|
||||
case 'request': return 'http.unknown';
|
||||
case 'timeout': return 'http.timeout';
|
||||
case 'network': return 'http.networkError';
|
||||
case 'unknown':
|
||||
default: return 'http.networkError';
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
classifyHttpError,
|
||||
httpErrorKey,
|
||||
};
|
||||
@@ -1,274 +1,289 @@
|
||||
import Config from './config.js'
|
||||
import Util from './util.js'
|
||||
import store from '@/store/index.js'
|
||||
import Platform from './platform.js'
|
||||
|
||||
const { appType, appTypeName } = Platform.getPlatformInfo();
|
||||
|
||||
let _Lang = null;
|
||||
function getLang() {
|
||||
if (!_Lang) {
|
||||
_Lang = require('./lang.js').default;
|
||||
}
|
||||
return _Lang;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 15000;
|
||||
const DEFAULT_MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_BASE = 2000;
|
||||
|
||||
function _shouldRetry(errMsg) {
|
||||
if (!errMsg) return false;
|
||||
if (errMsg === 'request:fail url not in domain list') return false;
|
||||
if (errMsg === 'request:fail abort') return false;
|
||||
const retryableErrors = [
|
||||
'request:fail timeout',
|
||||
'request:fail connect',
|
||||
'request:fail network'
|
||||
];
|
||||
return retryableErrors.some(err => errMsg.startsWith(err));
|
||||
}
|
||||
|
||||
function _classifyError(errMsg, statusCode) {
|
||||
if (!errMsg && !statusCode) return 'unknown';
|
||||
if (errMsg === 'request:fail abort') return 'aborted';
|
||||
if (errMsg && errMsg.indexOf('timeout') !== -1) return 'timeout';
|
||||
if (errMsg && (errMsg.indexOf('connect') !== -1 || errMsg.indexOf('network') !== -1)) return 'networkError';
|
||||
if (statusCode && statusCode >= 500) return 'serverError';
|
||||
if (errMsg && errMsg.indexOf('url not in domain list') !== -1) return 'domainNotInList';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function _sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: url,
|
||||
method: method,
|
||||
data: data,
|
||||
header: header,
|
||||
dataType: dataType,
|
||||
responseType: responseType,
|
||||
timeout: timeout,
|
||||
success: (res) => {
|
||||
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
|
||||
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
|
||||
return;
|
||||
}
|
||||
if (res.data.code == -3 && store.state.siteState > 0) {
|
||||
store.commit('setSiteState', -3);
|
||||
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
|
||||
return;
|
||||
}
|
||||
if (res.data.refreshtoken) {
|
||||
store.commit('setToken', res.data.refreshtoken);
|
||||
}
|
||||
if (res.data.code == -10009 || res.data.code == -10010) {
|
||||
store.commit('setToken', '');
|
||||
store.commit('setMemberInfo', '');
|
||||
}
|
||||
resolve(res.data);
|
||||
},
|
||||
fail: (res) => {
|
||||
reject(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _requestWithRetry(params, url, method, data, header, dataType, responseType) {
|
||||
const timeout = params.timeout || DEFAULT_TIMEOUT;
|
||||
const maxRetries = params.noRetry ? 0 : (params.maxRetries || DEFAULT_MAX_RETRIES);
|
||||
let lastError = null;
|
||||
let lastStatusCode = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
|
||||
return result;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
lastStatusCode = err && err.statusCode;
|
||||
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
|
||||
break;
|
||||
}
|
||||
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
|
||||
await _sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.silentError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const errorType = _classifyError(lastError && lastError.errMsg, lastStatusCode);
|
||||
const lang = getLang();
|
||||
if (errorType === 'domainNotInList') {
|
||||
uni.showToast({
|
||||
title: lang.lang('http.domainNotInList', { url: Config.baseUrl }),
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
} else {
|
||||
const i18nKey = 'http.' + errorType;
|
||||
uni.showToast({
|
||||
title: lang.lang(i18nKey),
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export default {
|
||||
sendRequest(params) {
|
||||
if (!Config.baseUrl) {
|
||||
uni.showToast({
|
||||
title: getLang().lang('http.baseUrlNotConfigured'),
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const _isRemoteOnly = getLang().isRemoteOnly && getLang().isRemoteOnly();
|
||||
const _bootDone = getLang().isBootCompleted && getLang().isBootCompleted();
|
||||
if (_isRemoteOnly && !_bootDone) {
|
||||
let bootPromise = null;
|
||||
try {
|
||||
const app = getApp();
|
||||
if (app && app.$langReady) bootPromise = app.$langReady;
|
||||
} catch (e) {}
|
||||
if (!bootPromise) bootPromise = getLang().boot();
|
||||
|
||||
if (params.async === false) {
|
||||
return bootPromise.then(() => this._sendRequestNow(params));
|
||||
} else {
|
||||
bootPromise.then(() => this._sendRequestNow(params));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return this._sendRequestNow(params);
|
||||
},
|
||||
|
||||
_sendRequestNow(params) {
|
||||
|
||||
var method = params.data != undefined ? 'POST' : 'GET',
|
||||
url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/'),
|
||||
data = {
|
||||
app_type: appType,
|
||||
app_type_name: appTypeName
|
||||
};
|
||||
|
||||
data.token = store.state.token || '';
|
||||
data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid;
|
||||
|
||||
var default_store_info = store.state.defaultStoreInfo;
|
||||
if (default_store_info) {
|
||||
data.store_id = default_store_info.store_id;
|
||||
}
|
||||
|
||||
var store_info = store.state.globalStoreInfo;
|
||||
|
||||
if (store_info) data.store_id = store_info.store_id;
|
||||
|
||||
if (params.data != undefined) Object.assign(data, params.data);
|
||||
|
||||
const header = params.header || {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
};
|
||||
const dataType = params.dataType || 'json';
|
||||
const responseType = params.responseType || 'text';
|
||||
|
||||
if (params.async === false) {
|
||||
return _requestWithRetry(params, url, method, data, header, dataType, responseType)
|
||||
.then(res => {
|
||||
typeof params.complete == 'function' && params.complete(res);
|
||||
return res;
|
||||
})
|
||||
.catch(err => {
|
||||
typeof params.complete == 'function' && params.complete();
|
||||
throw err;
|
||||
});
|
||||
} else {
|
||||
_requestWithRetry(params, url, method, data, header, dataType, responseType)
|
||||
.then(res => {
|
||||
typeof params.success == 'function' && params.success(res);
|
||||
typeof params.complete == 'function' && params.complete(res);
|
||||
})
|
||||
.catch(err => {
|
||||
typeof params.fail == 'function' && params.fail(err);
|
||||
typeof params.complete == 'function' && params.complete();
|
||||
});
|
||||
}
|
||||
},
|
||||
uploadBase64(params) {
|
||||
uni.request({
|
||||
url: Config.baseUrl + '/api/upload/headimgBase64',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
data: {
|
||||
app_type: appType,
|
||||
app_type_name: appTypeName,
|
||||
images: params.base64
|
||||
},
|
||||
dataType: 'json',
|
||||
responseType: 'text',
|
||||
success: res => {
|
||||
typeof params.success == 'function' && params.success(res.data);
|
||||
},
|
||||
fail: () => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
},
|
||||
pullImage(params) {
|
||||
uni.request({
|
||||
url: Config.baseUrl + '/api/upload/headimgPull',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
data: {
|
||||
app_type: appType,
|
||||
app_type_name: appTypeName,
|
||||
path: params.path
|
||||
},
|
||||
dataType: 'json',
|
||||
responseType: 'text',
|
||||
success: res => {
|
||||
typeof params.success == 'function' && params.success(res.data);
|
||||
},
|
||||
fail: () => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
},
|
||||
upload(params) {
|
||||
uni.uploadFile({
|
||||
url: Config.baseUrl + params.url,
|
||||
filePath: params.filePath,
|
||||
name: params.name || 'file',
|
||||
fileType: params.fileType || 'image',
|
||||
formData: {
|
||||
app_type: appType,
|
||||
app_type_name: appTypeName,
|
||||
},
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
success: (res) => {
|
||||
typeof params.success == 'function' && params.success(JSON.parse(res.data));
|
||||
},
|
||||
fail: (res) => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
import Config from './config.js'
|
||||
import Util from './util.js'
|
||||
import store from '@/store/index.js'
|
||||
import { Utils } from 'common/js/map-wx-jssdk.js';
|
||||
|
||||
// #ifdef H5
|
||||
const app_type = Util.isWeiXin() ? 'wechat' : 'h5';
|
||||
const app_type_name = Util.isWeiXin() ? '微信公众号' : 'H5';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const app_type = 'weapp';
|
||||
const app_type_name = '微信小程序';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
const app_type = 'aliapp';
|
||||
const app_type_name = '支付宝小程序';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-BAIDU
|
||||
const app_type = 'baiduapp';
|
||||
const app_type_name = '百度小程序';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-TOUTIAO
|
||||
const app_type = 'MP-TOUTIAO';
|
||||
const app_type_name = '头条小程序';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-QQ
|
||||
const app_type = 'MP-QQ';
|
||||
const app_type_name = 'QQ小程序';
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
const app_type = 'app';
|
||||
const app_type_name = 'APP';
|
||||
// #endif
|
||||
|
||||
export default {
|
||||
sendRequest(params) {
|
||||
if (!Config.baseUrl) {
|
||||
uni.showToast({
|
||||
title: '未配置请求域名',
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var method = params.data != undefined ? 'POST' : 'GET', // 请求方式
|
||||
url = Config.baseUrl + params.url, // 请求路径
|
||||
data = {
|
||||
app_type,
|
||||
app_type_name
|
||||
};
|
||||
|
||||
// token
|
||||
data.token = store.state.token || '';
|
||||
data.uniacid = Config.uniacid
|
||||
// 门店id
|
||||
var default_store_info = store.state.defaultStoreInfo;
|
||||
if (default_store_info) {
|
||||
data.store_id = default_store_info.store_id;
|
||||
}
|
||||
|
||||
var store_info = store.state.globalStoreInfo;
|
||||
|
||||
if (store_info) data.store_id = store_info.store_id;
|
||||
|
||||
// 参数
|
||||
if (params.data != undefined) Object.assign(data, params.data);
|
||||
|
||||
if (params.async === false) {
|
||||
//同步
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: url,
|
||||
method: method,
|
||||
data: data,
|
||||
header: params.header || {
|
||||
// 'Accept': 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
dataType: params.dataType || 'json',
|
||||
responseType: params.responseType || 'text',
|
||||
success: (res) => {
|
||||
// try {
|
||||
// res.data = JSON.parse(res.data);
|
||||
// } catch (e) {
|
||||
// //TODO handle the exception
|
||||
// console.log('api error:', e);
|
||||
// }
|
||||
if (res.data.code == -3 && store.state.siteState > 0) {
|
||||
store.commit('setSiteState', -3);
|
||||
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
|
||||
return;
|
||||
}
|
||||
if (res.data.refreshtoken) {
|
||||
store.commit('setToken', res.data.refreshtoken);
|
||||
}
|
||||
if (res.data.code == -10009 || res.data.code == -10010) {
|
||||
store.commit('setToken', '');
|
||||
store.commit('setMemberInfo', '');
|
||||
}
|
||||
resolve(res.data);
|
||||
},
|
||||
fail: (res) => {
|
||||
if (res.errMsg && res.errMsg == 'request:fail url not in domain list') {
|
||||
uni.showToast({
|
||||
title: Config.baseUrl + '不在request 合法域名列表中',
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
reject(res);
|
||||
},
|
||||
complete: (res) => {
|
||||
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
|
||||
uni.showToast({
|
||||
title: Config.baseUrl + '请求失败',
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
reject(res.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
//异步
|
||||
uni.request({
|
||||
url: url,
|
||||
method: method,
|
||||
data: data,
|
||||
header: params.header || {
|
||||
// 'Accept': 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
dataType: params.dataType || 'json',
|
||||
responseType: params.responseType || 'text',
|
||||
success: (res) => {
|
||||
// try {
|
||||
// res.data = JSON.parse(res.data);
|
||||
// } catch (e) {
|
||||
// //TODO handle the exception
|
||||
// console.log('api error:', e);
|
||||
// }
|
||||
if (res.data.code == -3 && store.state.siteState > 0) {
|
||||
store.commit('setSiteState', -3);
|
||||
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
|
||||
return;
|
||||
}
|
||||
if (res.data.refreshtoken) {
|
||||
store.commit('setToken', res.data.refreshtoken);
|
||||
}
|
||||
if (res.data.code == -10009 || res.data.code == -10010) {
|
||||
store.commit('setToken', '');
|
||||
store.commit('setMemberInfo', '');
|
||||
}
|
||||
typeof params.success == 'function' && params.success(res.data);
|
||||
},
|
||||
fail: (res) => {
|
||||
if (res.errMsg && res.errMsg == 'request:fail url not in domain list') {
|
||||
uni.showToast({
|
||||
title: Config.baseUrl + '不在request 合法域名列表中',
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
},
|
||||
complete: (res) => {
|
||||
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
|
||||
uni.showToast({
|
||||
title: Config.baseUrl + '请求失败',
|
||||
'icon': 'none',
|
||||
duration: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
typeof params.complete == 'function' && params.complete(res.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
needMd5Fn(params,callback) {
|
||||
uni.request({
|
||||
url: Config.baseUrl + '/api/config/getApiConfig',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
data: {
|
||||
app_type,
|
||||
app_type_name,
|
||||
},
|
||||
dataType: 'json',
|
||||
responseType: 'text',
|
||||
success: res => {
|
||||
var sign_str = Utils.hexMD5('key=' + res.data.data.key+'&time='+res.data.data.time)
|
||||
typeof callback == 'function' && callback(sign_str,res.data.data.time);
|
||||
},
|
||||
fail: () => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
},
|
||||
uploadBase64(params) {
|
||||
this.needMd5Fn(params,(sign_str,time)=>{
|
||||
uni.request({
|
||||
url: Config.baseUrl + '/api/upload/headimgBase64',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
data: {
|
||||
app_type,
|
||||
app_type_name,
|
||||
images: params.base64,
|
||||
token: store.state.token || '',
|
||||
sign: sign_str,
|
||||
time: time
|
||||
},
|
||||
dataType: 'json',
|
||||
responseType: 'text',
|
||||
success: res => {
|
||||
typeof params.success == 'function' && params.success(res.data);
|
||||
},
|
||||
fail: () => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
pullImage(params) {
|
||||
this.needMd5Fn(params,(sign_str,time)=>{
|
||||
uni.request({
|
||||
url: Config.baseUrl + '/api/upload/headimgPull',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
data: {
|
||||
app_type,
|
||||
app_type_name,
|
||||
path: params.path,
|
||||
token: store.state.token || '',
|
||||
sign: sign_str,
|
||||
time: time
|
||||
},
|
||||
dataType: 'json',
|
||||
responseType: 'text',
|
||||
success: res => {
|
||||
typeof params.success == 'function' && params.success(res.data);
|
||||
},
|
||||
fail: () => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
upload(params) {
|
||||
uni.uploadFile({
|
||||
url: Config.baseUrl + params.url,
|
||||
filePath: params.filePath,
|
||||
name: params.name || 'file',
|
||||
fileType: params.fileType || 'image',
|
||||
formData: {
|
||||
app_type,
|
||||
app_type_name,
|
||||
token: store.state.token || ''
|
||||
},
|
||||
header: {
|
||||
'content-type': 'application/x-www-form-urlencoded;application/json'
|
||||
},
|
||||
success: (res) => {
|
||||
typeof params.success == 'function' && params.success(JSON.parse(res.data));
|
||||
},
|
||||
fail: (res) => {
|
||||
typeof params.fail == 'function' && params.fail(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 图片配置文件
|
||||
* 将大图片上传到服务器后,请在此处配置网络URL
|
||||
*/
|
||||
|
||||
// 替换为你上传后的实际图片URL
|
||||
const IMAGE_CONFIG = {
|
||||
// AI客服头像
|
||||
aiAvatar: 'public/static/img/s-agent.jpg',
|
||||
|
||||
// 用户默认头像
|
||||
userAvatar: 'public/static/img/s-user.jpg'
|
||||
};
|
||||
|
||||
export default IMAGE_CONFIG;
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* remote-only 模式下的空语言包 stub
|
||||
*
|
||||
* 当 source === 'remote-only' 时,webpack NormalModuleReplacementPlugin
|
||||
* 会把所有 @/lang/ 下的 require 替换为这个文件,
|
||||
* 避免本地 lang/ 目录被打进产物。
|
||||
*
|
||||
* 导出格式与真实语言包一致: { lang: {} }
|
||||
*/
|
||||
export const lang = {};
|
||||
export default { lang };
|
||||
@@ -1,440 +0,0 @@
|
||||
/**
|
||||
* 远端语言包 bundle 加载器
|
||||
*
|
||||
* 职责:
|
||||
* 1. 从后端 /api/lang/bundle 拉取完整语言树
|
||||
* 2. 做本地 storage 缓存 + 内存缓存
|
||||
* 3. 支持签名校验(可选)
|
||||
* 4. 支持 diff 增量更新(预留)
|
||||
* 5. 对外暴露 loadBundleTree / getCachedBundle / clearBundleCache
|
||||
*
|
||||
* 与 lang.js 的关系:
|
||||
* 当 langConfig.source === 'remote-only' 时,lang.js 不再 require 本地 lang/ 文件,
|
||||
* 而是直接复用本模块加载的 tree。
|
||||
*/
|
||||
import { langConfig } from './config-external.js';
|
||||
import Config from './config.js';
|
||||
import { Platform } from './platform.js';
|
||||
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
// 内存缓存: { [memKey]: { version, tree, ts } }
|
||||
const bundleCache = {};
|
||||
|
||||
// 关键修复: 缓存 fetchBundle 迟到的成功结果
|
||||
// 即使 Promise.race 已 timeout, uni.request 内部可能继续完成
|
||||
// 缓存这份迟到结果, 给上层 catch 后的"补救"逻辑使用
|
||||
// 结构: { `${lang}_${appType}_${uniacid}`: { data, ts, lang, appType, uniacid } }
|
||||
const _lastSuccessfulResult = {};
|
||||
|
||||
// 缓存 TTL(与 BUNDLE_CACHE_TTL 一致): 10 分钟
|
||||
const _LAST_RESULT_TTL = 10 * 60 * 1000;
|
||||
|
||||
// 并发去重
|
||||
const bundleInflight = {};
|
||||
|
||||
/**
|
||||
* 三维隔离 key: <uniacid>_<appType>_<lang>
|
||||
*/
|
||||
function getBundleMemKey(uniacid, appType, lang) {
|
||||
return `${uniacid || 0}_${appType || '*'}_${lang}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* storage key: 与 memKey 维度一致
|
||||
*/
|
||||
function getBundleStorageKey(uniacid, appType, lang) {
|
||||
return `${langConfig.remote.storagePrefix || 'LANG_BUNDLE_'}${getBundleMemKey(uniacid, appType, lang)}`;
|
||||
}
|
||||
|
||||
function getCurrentLocale() {
|
||||
try {
|
||||
return uni.getStorageSync('lang') || langConfig.defaultLang || 'zh-cn';
|
||||
} catch (e) {
|
||||
return langConfig.defaultLang || 'zh-cn';
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentUniacid() {
|
||||
try {
|
||||
const u = uni.getStorageSync('uniacid');
|
||||
return u ? parseInt(u, 10) || 0 : 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentAppType() {
|
||||
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度合并两个对象,source 覆盖 target
|
||||
*/
|
||||
function deepMerge(target, source) {
|
||||
if (target === null || typeof target !== 'object') return source;
|
||||
if (source === null || typeof source !== 'object') return source;
|
||||
if (Array.isArray(target) || Array.isArray(source)) return source;
|
||||
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
||||
if (FORBIDDEN_KEYS.has(key)) continue;
|
||||
const srcVal = source[key];
|
||||
const tgtVal = target[key];
|
||||
if (
|
||||
srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
|
||||
tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
|
||||
) {
|
||||
target[key] = deepMerge(tgtVal, srcVal);
|
||||
} else {
|
||||
target[key] = srcVal;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易签名校验
|
||||
* 后端: hash_hmac('sha256', version + json_encode(tree), secret)
|
||||
* 前端: 同样算法
|
||||
*/
|
||||
function validateSign(tree, version, sign, secret) {
|
||||
if (!secret || !sign) return true;
|
||||
// 前端没有 crypto-js,用简单字符串拼接做校验(仅基础防篡改)
|
||||
// 如需强校验,建议引入 crypto-js 或小程序/APP 端使用原生加密 API
|
||||
// 关键修复: 用 try-catch 保护 btoa(),避免在小程序环境(无 btoa)中抛 ReferenceError
|
||||
let _btoaResult = '';
|
||||
try {
|
||||
const payload = JSON.stringify(tree);
|
||||
// 兼容小程序环境: 没有原生 btoa 时降级
|
||||
if (typeof btoa === 'function') {
|
||||
_btoaResult = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
||||
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
// 微信小程序/uni-app 提供 arrayBufferToBase64
|
||||
const _encoder = (typeof TextEncoder !== 'undefined')
|
||||
? new TextEncoder().encode(version + ':' + payload)
|
||||
: null;
|
||||
if (_encoder) {
|
||||
_btoaResult = uni.arrayBufferToBase64(_encoder.buffer).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 签名计算失败也不应阻断 bundle 加载,静默失败
|
||||
}
|
||||
// 后端返回的是 hash_hmac('sha256', version + payload, secret)
|
||||
// 这里只做格式校验,不强校验(因为无 crypto 库)
|
||||
return typeof sign === 'string' && sign.length > 0;
|
||||
}
|
||||
|
||||
// 缓存 TTL(ms): 与后端 Cache::set 600s 对齐,过期后后台刷新
|
||||
const BUNDLE_CACHE_TTL = (langConfig.remote.cacheTtl || 600) * 1000;
|
||||
|
||||
/**
|
||||
* 从 storage 读取缓存(检查 TTL 过期)
|
||||
* @returns {Object|null} 未过期返回 {tree,version,ts}, 过期返回 null
|
||||
*/
|
||||
function readStorageCache(sk) {
|
||||
try {
|
||||
const cached = uni.getStorageSync(sk);
|
||||
if (cached && cached.tree && typeof cached.tree === 'object') {
|
||||
// TTL 检查: 超过 cacheTtl 视为过期
|
||||
if (cached.ts && (Date.now() - cached.ts < BUNDLE_CACHE_TTL)) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查内存缓存是否过期
|
||||
*/
|
||||
function isMemoryCacheExpired(record) {
|
||||
if (!record || !record.ts) return true;
|
||||
return (Date.now() - record.ts) >= BUNDLE_CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 storage 缓存
|
||||
*/
|
||||
function writeStorageCache(sk, data) {
|
||||
try {
|
||||
uni.setStorageSync(sk, data);
|
||||
} catch (e) {
|
||||
// storage 写满时静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端拉取 bundle
|
||||
*
|
||||
* 关键修复: 即使 Promise.race 在 timeout 时已经 reject,
|
||||
* uni.request 内部仍可能继续执行并成功返回。
|
||||
* 之前这些"迟到"的成功响应被完全丢弃,导致:
|
||||
* 1. fetchBundle 抛 timeout 错
|
||||
* 2. _bootImpl 进 catch, 不写 remoteTreeCache
|
||||
* 3. uni.request 终于成功, 但没人接收, 结果被 GC
|
||||
* 现在改为: uni.request 完成后, **无论 Promise.race 状态如何**,
|
||||
* 都把结果写入 _lastSuccessfulResult 缓存。
|
||||
* _bootImpl 可以在 catch 后**主动等待**这份迟到数据(最多 3 秒),
|
||||
* 从而真正用上远端返回的完整 bundle。
|
||||
*/
|
||||
async function fetchBundle(lang, appType, uniacid, timeout) {
|
||||
const url = langConfig.remote.bundleUrl || '/api/lang/bundle';
|
||||
const data = { lang };
|
||||
if (appType && appType !== '*') data.app_type = appType;
|
||||
// uniacid 必须传给后端,后端按 (uniacid × app_type) 二维查询租户专属覆写
|
||||
if (uniacid !== undefined && uniacid !== null) data.uniacid = uniacid;
|
||||
|
||||
// 直接用 uni.request 包装 Promise,不依赖 http.sendRequest
|
||||
// (sendRequest 的异步分支不返回 Promise,只有 async:false 分支才返回)
|
||||
// 去重双斜杠: baseUrl 末尾可能带 /, bundleUrl 开头也带 /
|
||||
const fullUrl = (Config.baseUrl || '').replace(/\/+$/, '') + url;
|
||||
|
||||
// 关键修复: 用一个 "completionLatch" 跟踪 uni.request 是否最终完成(成功或失败)
|
||||
// 即使 Promise.race 已经 timeout reject, uni.request 内部仍可能继续完成
|
||||
// 我们保留这份迟到结果到 _lastSuccessfulResult, 给上层补救的机会
|
||||
const inflightKey = `${lang}_${appType}_${uniacid}`;
|
||||
|
||||
const res = await Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: fullUrl,
|
||||
method: 'POST',
|
||||
data: data,
|
||||
header: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
dataType: 'json',
|
||||
success: (response) => {
|
||||
// 关键: 即使 Promise.race 已 reject, 仍然把成功结果写入全局缓存
|
||||
// 给 loadBundleTree catch 后 "补救" 的机会
|
||||
try {
|
||||
if (response && response.data && response.data.code === 0 && response.data.data && response.data.data.tree) {
|
||||
_lastSuccessfulResult[inflightKey] = {
|
||||
data: response.data,
|
||||
ts: Date.now(),
|
||||
lang, appType, uniacid,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
resolve(response.data);
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error(err.errMsg || 'bundle request failed'));
|
||||
},
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => setTimeout(() => {
|
||||
reject(new Error('timeout'));
|
||||
}, timeout)),
|
||||
]);
|
||||
|
||||
if (!res || res.code !== 0 || !res.data) {
|
||||
throw new Error(res && res.message ? res.message : 'bundle fetch failed');
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 bundle tree
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} lang 语言
|
||||
* @param {string} [appType] 平台类型
|
||||
* @param {number} [uniacid] 租户 id
|
||||
* @param {boolean} [allowCache=true] 是否允许使用缓存
|
||||
* @param {boolean} [force=false] 是否强制刷新
|
||||
* @returns {Promise<{tree:Object, version:string, source:string}>}
|
||||
*/
|
||||
export async function loadBundleTree(options = {}) {
|
||||
const lang = options.lang || getCurrentLocale();
|
||||
const appType = options.appType || getCurrentAppType();
|
||||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||
const allowCache = options.allowCache !== false;
|
||||
const force = options.force === true;
|
||||
|
||||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||
|
||||
if (bundleInflight[mk]) {
|
||||
return bundleInflight[mk];
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
// 1) 内存缓存未过期直接返回
|
||||
if (!force && allowCache && bundleCache[mk] && !isMemoryCacheExpired(bundleCache[mk])) {
|
||||
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' };
|
||||
}
|
||||
|
||||
// 2) 读取 storage 缓存(已含 TTL 检查)
|
||||
let cached = null;
|
||||
if (!force && allowCache) {
|
||||
cached = readStorageCache(sk);
|
||||
if (cached && !bundleCache[mk]) {
|
||||
bundleCache[mk] = cached;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 远端拉取
|
||||
const timeout = langConfig.remote.requestTimeout || 4000;
|
||||
const payload = await fetchBundle(lang, appType, uniacid, timeout);
|
||||
const tree = payload.tree || {};
|
||||
const version = String(payload.version || '');
|
||||
const sign = payload.sign || '';
|
||||
|
||||
// 4) 签名校验(可选)
|
||||
const secret = langConfig.remote.signSecret || '';
|
||||
const signValid = validateSign(tree, version, sign, secret);
|
||||
if (!signValid) {
|
||||
throw new Error('bundle sign invalid');
|
||||
}
|
||||
|
||||
// 5) 更新缓存
|
||||
const record = { version, tree, ts: Date.now() };
|
||||
bundleCache[mk] = record;
|
||||
writeStorageCache(sk, record);
|
||||
|
||||
return { tree, version, source: 'remote' };
|
||||
} catch (e) {
|
||||
// 关键修复: 补救迟到数据
|
||||
// fetchBundle 因 timeout 抛错时, uni.request 内部可能仍然成功(只是没 Promise 接收)
|
||||
// 之前这些"迟到"数据被完全丢弃,导致 boot 失败后永远等不到远端数据
|
||||
// 现在: catch 后等待 _lastSuccessfulResult 出现(最多 3 秒), 期间如果迟到数据到达则用之
|
||||
const inflightKey = `${lang}_${appType}_${uniacid}`;
|
||||
// 等待 _lastSuccessfulResult 出现(超时后 uni.request 内部 success 回调可能稍后才触发)
|
||||
const waitStart = Date.now();
|
||||
const WAIT_MAX_MS = 3000;
|
||||
const WAIT_POLL_MS = 100;
|
||||
while (Date.now() - waitStart < WAIT_MAX_MS) {
|
||||
const lateResult = _lastSuccessfulResult[inflightKey];
|
||||
if (lateResult && (Date.now() - lateResult.ts) < _LAST_RESULT_TTL) {
|
||||
// 有"迟到"数据, 直接用
|
||||
const resp = lateResult.data;
|
||||
// resp 结构: { code, message, data: { version, tree, sign } }
|
||||
const dataNode = resp && resp.data;
|
||||
const tree = (dataNode && dataNode.tree) || {};
|
||||
const version = String((dataNode && dataNode.version) || '');
|
||||
const sign = (dataNode && dataNode.sign) || '';
|
||||
const signValid = validateSign(tree, version, sign, langConfig.remote.signSecret || '');
|
||||
if (signValid) {
|
||||
const record = { version, tree, ts: Date.now() };
|
||||
bundleCache[mk] = record;
|
||||
writeStorageCache(sk, record);
|
||||
return { tree, version, source: 'remote-late' };
|
||||
}
|
||||
}
|
||||
// 等待 WAIT_POLL_MS
|
||||
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
|
||||
}
|
||||
// 6) 失败时回退到缓存
|
||||
// 关键修复: force=true 时**不回退**到 bundleCache
|
||||
// 旧逻辑: force 失败 → 回退到旧 cache → 标记新 ts → 永远不被刷新 → bundle 缺 key
|
||||
// 新逻辑: force 失败 → 抛错, 让上层 _bootImpl 进入 catch 块走真正的"无 cache 兜底"
|
||||
if (force) {
|
||||
throw e;
|
||||
}
|
||||
if (allowCache && bundleCache[mk]) {
|
||||
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'cache', error: e.message };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
delete bundleInflight[mk];
|
||||
}
|
||||
})();
|
||||
|
||||
bundleInflight[mk] = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已缓存的 bundle(不触发网络请求)
|
||||
* 注意: 受 TTL 限制,过期缓存返回 null
|
||||
*/
|
||||
export function getCachedBundle(options = {}) {
|
||||
const lang = options.lang || getCurrentLocale();
|
||||
const appType = options.appType || getCurrentAppType();
|
||||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||
|
||||
if (bundleCache[mk]) return bundleCache[mk];
|
||||
const cached = readStorageCache(sk);
|
||||
if (cached) {
|
||||
bundleCache[mk] = cached;
|
||||
return cached;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任意已缓存的 bundle(不检查 TTL),仅用于 boot 失败兜底
|
||||
*
|
||||
* 与 getCachedBundle 的区别:
|
||||
* - getCachedBundle: 受 BUNDLE_CACHE_TTL 限制,过期返回 null(用于日常 $lang 渲染)
|
||||
* - getCachedBundleAny: 不检查 TTL,只要内存或 storage 中有就返回(用于失败兜底)
|
||||
*
|
||||
* 设计意图:
|
||||
* boot() 强制重试失败时,如果用户本地还有哪怕过期的 bundle,
|
||||
* 也比"$lang() 全返回空串"用户体验更好 — 至少能展示残缺文案。
|
||||
* 这是显式的"宁可用过期缓存,也不空白"的取舍。
|
||||
*
|
||||
* 优先返回内存缓存(更快),其次返回 storage 缓存。
|
||||
*
|
||||
* @returns {{version:string, tree:Object, ts:number, source:'memory'|'storage'}|null}
|
||||
*/
|
||||
export function getCachedBundleAny(options = {}) {
|
||||
const lang = options.lang || getCurrentLocale();
|
||||
const appType = options.appType || getCurrentAppType();
|
||||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||
|
||||
// 1) 内存缓存优先(任意 TTL,过期也返回)
|
||||
if (bundleCache[mk] && bundleCache[mk].tree) {
|
||||
return { ...bundleCache[mk], source: 'memory' };
|
||||
}
|
||||
|
||||
// 2) storage 兜底(任意 TTL,不调用 readStorageCache 因为它内部会查 TTL)
|
||||
try {
|
||||
const raw = uni.getStorageSync(sk);
|
||||
if (raw && raw.tree && typeof raw.tree === 'object') {
|
||||
// 写回内存缓存,方便下次 getCachedBundle / getCachedBundleAny 命中
|
||||
bundleCache[mk] = raw;
|
||||
return { ...raw, source: 'storage' };
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 bundle 缓存
|
||||
*/
|
||||
export function clearBundleCache(options = {}) {
|
||||
const lang = options.lang || getCurrentLocale();
|
||||
const appType = options.appType || getCurrentAppType();
|
||||
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||
|
||||
delete bundleCache[mk];
|
||||
try { uni.removeStorageSync(sk); } catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有缓存可用(用于启动时快速判断)
|
||||
*/
|
||||
export function hasBundleCache(options = {}) {
|
||||
return getCachedBundle(options) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地语言包与远端 bundle tree(混合模式用)
|
||||
*/
|
||||
export function mergeLocalWithBundle(localTree, bundleTree) {
|
||||
if (!localTree || typeof localTree !== 'object') return bundleTree || {};
|
||||
if (!bundleTree || typeof bundleTree !== 'object') return localTree || {};
|
||||
return deepMerge(deepMerge({}, localTree), bundleTree);
|
||||
}
|
||||
1570
common/js/lang.js
1570
common/js/lang.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,108 +1,107 @@
|
||||
import util from '@/common/js/util.js'
|
||||
import TransformCoordinate from './transformCoordinate.js'
|
||||
import Lang from '@/common/js/lang.js'
|
||||
|
||||
function openMapByDefault(latitude, longitude, name) {
|
||||
uni.openLocation({
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
name: name,
|
||||
fail: (e) => {
|
||||
uni.showModal({
|
||||
content: Lang.lang('common.map.openMapFailed')
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openMapByAndroid(latitude, longitude, name) {
|
||||
let url = ''; // 回调地址
|
||||
let identity = ''; // 程序名称
|
||||
if (plus.runtime.isApplicationExist({
|
||||
pname: 'com.baidu.BaiduMap'
|
||||
})) {
|
||||
url =
|
||||
`baidumap://map/marker?location=${latitude},${longitude}&title=${name}&coord_type=gcj02&src=andr.baidu.openAPIdemo`
|
||||
identity = 'com.baidu.BaiduMap'
|
||||
openURL(url, identity)
|
||||
} else if (plus.runtime.isApplicationExist({
|
||||
pname: 'com.autonavi.minimap'
|
||||
})) { // 高德
|
||||
url = `androidamap://viewMap?sourceApplication=appname&poiname=${name}&lat=${latitude}&lon=${longitude}&dev=0`
|
||||
identity = 'com.autonavi.minimap'
|
||||
openURL(url, identity)
|
||||
} else {
|
||||
openMapByDefault(latitude, longitude, name)
|
||||
}
|
||||
}
|
||||
|
||||
function openMapByIos(latitude, longitude, name) {
|
||||
let url = ''; // 回调地址
|
||||
let errorCB = ''; // url失败的回调地址
|
||||
let identity = ''; // 程序名称
|
||||
|
||||
if (plus.runtime.isApplicationExist({
|
||||
action: 'baidumap://'
|
||||
})) {
|
||||
url =
|
||||
`baidumap://map/marker?location=${latitude},${longitude}&title=${name}&content=${name}&src=ios.baidu.openAPIdemo&coord_type=gcj02`;
|
||||
openURL(url, identity)
|
||||
} else if (plus.runtime.isApplicationExist({
|
||||
action: 'iosamap://'
|
||||
})) { // 高德
|
||||
url = `iosamap://viewMap?sourceApplication=applicationName&poiname=${name}&lat=${latitude}&lon=${longitude}&dev=0`
|
||||
openURL(url, identity)
|
||||
} else {
|
||||
openMapByDefault(latitude, longitude, name)
|
||||
}
|
||||
}
|
||||
|
||||
function openURL(url, identity) {
|
||||
let newurl = encodeURI(url);
|
||||
plus.runtime.openURL(newurl, function(res) {
|
||||
uni.showModal({
|
||||
content: res.message
|
||||
})
|
||||
}, identity);
|
||||
}
|
||||
|
||||
function getCoordByType(longitude, latitude, coord_type) {
|
||||
switch (coord_type) {
|
||||
case 'gcj02':
|
||||
return [longitude, latitude]
|
||||
break;
|
||||
case 'bd09':
|
||||
return TransformCoordinate.bd09togcj02(longitude, latitude)
|
||||
break;
|
||||
case 'wgs84':
|
||||
return TransformCoordinate.wgs84togcj02(longitude, latitude)
|
||||
break;
|
||||
default:
|
||||
return [longitude, latitude]
|
||||
break;
|
||||
}
|
||||
}
|
||||
export default {
|
||||
/* 打开地图 */
|
||||
openMap(latitude, longitude, name, coord_type = 'gcj02') {
|
||||
let arr = getCoordByType(longitude, latitude, coord_type)
|
||||
// #ifdef APP-PLUS
|
||||
let platform = util.getDeviceInfo().platform;
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
case 'harmony':
|
||||
openMapByAndroid(arr[1], arr[0], name)
|
||||
break;
|
||||
case 'ios':
|
||||
openMapByIos(arr[1], arr[0], name)
|
||||
break;
|
||||
default:
|
||||
openMapByDefault(arr[1], arr[0], name)
|
||||
break;
|
||||
}
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
openMapByDefault(arr[1], arr[0], name)
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
import TransformCoordinate from './transformCoordinate.js'
|
||||
|
||||
function openMapByDefault(latitude, longitude, name) {
|
||||
uni.openLocation({
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
name: name,
|
||||
fail: (e) => {
|
||||
uni.showModal({
|
||||
content: '打开地图失败,请稍后重试'
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openMapByAndroid(latitude, longitude, name) {
|
||||
let url = ''; // 回调地址
|
||||
let identity = ''; // 程序名称
|
||||
if (plus.runtime.isApplicationExist({
|
||||
pname: 'com.baidu.BaiduMap'
|
||||
})) {
|
||||
url = `baidumap://map/marker?location=${latitude},${longitude}&title=${name}&coord_type=gcj02&src=andr.baidu.openAPIdemo`;
|
||||
identity = 'com.baidu.BaiduMap';
|
||||
openURL(url, identity)
|
||||
} else if (plus.runtime.isApplicationExist({
|
||||
pname: 'com.autonavi.minimap'
|
||||
})) {
|
||||
// 高德
|
||||
url = `androidamap://viewMap?sourceApplication=appname&poiname=${name}&lat=${latitude}&lon=${longitude}&dev=0`;
|
||||
identity = 'com.autonavi.minimap';
|
||||
openURL(url, identity)
|
||||
} else {
|
||||
openMapByDefault(latitude, longitude, name)
|
||||
}
|
||||
}
|
||||
|
||||
function openMapByIos(latitude, longitude, name) {
|
||||
let url = ''; // 回调地址
|
||||
let errorCB = ''; // url失败的回调地址
|
||||
let identity = ''; // 程序名称
|
||||
|
||||
if (plus.runtime.isApplicationExist({
|
||||
action: 'baidumap://'
|
||||
})) {
|
||||
url = `baidumap://map/marker?location=${latitude},${longitude}&title=${name}&content=${name}&src=ios.baidu.openAPIdemo&coord_type=gcj02`;
|
||||
openURL(url, identity)
|
||||
} else if (plus.runtime.isApplicationExist({
|
||||
action: 'iosamap://'
|
||||
})) {
|
||||
// 高德
|
||||
url = `iosamap://viewMap?sourceApplication=applicationName&poiname=${name}&lat=${latitude}&lon=${longitude}&dev=0`;
|
||||
openURL(url, identity)
|
||||
} else {
|
||||
openMapByDefault(latitude, longitude, name)
|
||||
}
|
||||
}
|
||||
|
||||
function openURL(url, identity) {
|
||||
let newurl = encodeURI(url);
|
||||
plus.runtime.openURL(newurl, function(res) {
|
||||
uni.showModal({
|
||||
content: res.message
|
||||
})
|
||||
}, identity);
|
||||
}
|
||||
|
||||
function getCoordByType(longitude, latitude, coord_type) {
|
||||
switch (coord_type) {
|
||||
case 'gcj02':
|
||||
return [longitude, latitude]
|
||||
break;
|
||||
case 'bd09':
|
||||
return TransformCoordinate.bd09togcj02(longitude, latitude)
|
||||
break;
|
||||
case 'wgs84':
|
||||
return TransformCoordinate.wgs84togcj02(longitude, latitude)
|
||||
break;
|
||||
default:
|
||||
return [longitude, latitude]
|
||||
break;
|
||||
}
|
||||
}
|
||||
export default {
|
||||
/* 打开地图 */
|
||||
openMap(latitude, longitude, name, coord_type = 'gcj02') {
|
||||
let arr = getCoordByType(longitude, latitude, coord_type)
|
||||
// #ifdef APP-PLUS
|
||||
switch (uni.getSystemInfoSync().platform) {
|
||||
case 'android':
|
||||
console.log('运行Android上')
|
||||
openMapByAndroid(arr[1], arr[0], name)
|
||||
break;
|
||||
case 'ios':
|
||||
console.log('运行iOS上')
|
||||
openMapByIos(arr[1], arr[0], name)
|
||||
break;
|
||||
default:
|
||||
openMapByDefault(arr[1], arr[0], name)
|
||||
console.log('运行在开发者工具上')
|
||||
break;
|
||||
}
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
openMapByDefault(arr[1], arr[0], name)
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 主题 token 合并工具
|
||||
*
|
||||
* 与 lang.js::applyOverrides 同构(都是"远端扁平覆写 map"按 key 路径合并),
|
||||
* 差异:
|
||||
* - 主题 token 是"扁平叶子"(如 main_color / bg_color),没有嵌套层级
|
||||
* - 需要支持"三桶叠加":presetBase(本地预设) < brand(品牌色) < userMode(夜间/浅色)
|
||||
* - value 是色值字符串(也允许空串 = 显式清空)
|
||||
*
|
||||
* 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链
|
||||
*/
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/**
|
||||
* 把单层 map 的 keys 过滤掉危险段(白名单:值必须是 string,null/undefined/其它跳过)
|
||||
* - 输入: 远端扁平 map { 'main_color': '#FF4646', '...': null, ... }
|
||||
* - 输出: 净化后的 map(只保留 string 值,空串也是合法覆写)
|
||||
*/
|
||||
export function sanitizeFlatMap(flatMap) {
|
||||
const out = {};
|
||||
if (!flatMap || typeof flatMap !== 'object') return out;
|
||||
for (const key in flatMap) {
|
||||
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
|
||||
// 危险 key 过滤
|
||||
if (FORBIDDEN_KEYS.has(String(key))) continue;
|
||||
const val = flatMap[key];
|
||||
if (typeof val !== 'string') continue;
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多桶 map
|
||||
* @param {...object} maps 从低优先级到高优先级依次传入
|
||||
* 例: mergeThemeBuckets(presetBase, brand, dark) = preset 被 brand 覆盖,brand 被 dark 覆盖
|
||||
* @returns {object}
|
||||
*/
|
||||
export function mergeThemeBuckets(...maps) {
|
||||
const out = {};
|
||||
for (const m of maps) {
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
for (const k in m) {
|
||||
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
|
||||
if (FORBIDDEN_KEYS.has(String(k))) continue;
|
||||
const v = m[k];
|
||||
if (typeof v !== 'string') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一站式合并:本地预设(已选中的 theme 子集) + brand 桶 + userMode 桶
|
||||
* @param {object} preset 本地 style_color.js 中选中的 theme(如 'default' 的所有 token)
|
||||
* @param {object} brandOverrides brand 桶(任何模式都生效)
|
||||
* @param {object} userModeOverrides userMode 桶(light/dark,叠加在 brand 之上)
|
||||
* @returns {object} { tokens, sources }
|
||||
* tokens: 最终扁平 map
|
||||
* sources: 每个 key 的来源(用于调试/可视化) 'preset' | 'brand' | 'userMode'
|
||||
*/
|
||||
export function composeFinalTokens(preset, brandOverrides, userModeOverrides) {
|
||||
const tokens = {};
|
||||
const sources = {};
|
||||
const presetMap = sanitizeFlatMap(preset);
|
||||
const brandMap = sanitizeFlatMap(brandOverrides);
|
||||
const modeMap = sanitizeFlatMap(userModeOverrides);
|
||||
|
||||
for (const k in presetMap) { tokens[k] = presetMap[k]; sources[k] = 'preset'; }
|
||||
for (const k in brandMap) { tokens[k] = brandMap[k]; sources[k] = 'brand'; }
|
||||
for (const k in modeMap) { tokens[k] = modeMap[k]; sources[k] = 'userMode'; }
|
||||
return { tokens, sources };
|
||||
}
|
||||
|
||||
export default {
|
||||
sanitizeFlatMap,
|
||||
mergeThemeBuckets,
|
||||
composeFinalTokens,
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* 全局兜底分享 mixin
|
||||
*
|
||||
* 仅在微信小程序编译产物中被加载:
|
||||
* main.js 中的 import + Vue.mixin 调用被 MP-WEIXIN 预处理宏 包住,
|
||||
* 其它平台编译时整个模块不会进入产物,因此文件内部无需再写条件编译。
|
||||
*
|
||||
* 背景:
|
||||
* - 微信小程序要求每个要支持「转发 / 朋友圈」的页面显式声明 onShareAppMessage / onShareTimeline,
|
||||
* 否则页面右上角"三个点"菜单不会显示"转发"按钮。
|
||||
* - 本项目之前只有 ~17 个页面声明了分享钩子,剩余页面无法转发。
|
||||
* - 业务对"全页面都支持转发 + 复制小程序路径"有强诉求。
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 不侵入现有页面:所有页面通过 Vue.mixin 自动获得默认行为
|
||||
* 2. 已有 onShareAppMessage / onShareTimeline 的页面优先级更高(uni-app 会优先用页面自身的钩子)
|
||||
* 3. 默认分享内容由后端 /weapp/api/weapp/share 按当前路由下发,
|
||||
* 拉取失败时退化到站点名 + 当前路径,避免完全无法分享
|
||||
* 4. 暴露 $mpShare 工具方法(currentPath / copyPath / refresh),
|
||||
* 方便子页面在 onShareAppMessage 中复用 path
|
||||
*/
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const _safeGetCurrentPage = () => {
|
||||
try {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [];
|
||||
return pages.length > 0 ? pages[pages.length - 1] : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭分享能力的页面 route 列表(不带前导斜杠,与 mp-weixin page.route 保持一致)
|
||||
*
|
||||
* 为什么需要:
|
||||
* mp-share-default.js 通过 Vue.mixin 全局注入 onShareAppMessage / onShareTimeline,
|
||||
* 但有些页面不适合"分享":
|
||||
* 1) webview 容器页: 转发它等于转发一个 iframe 壳,无意义且会暴露内部 src
|
||||
* 2) 隐私弹窗 / 登录中转页: 状态不稳定,转出去体验差
|
||||
* 3) 任何业务明确要求"不分享"的页面
|
||||
*
|
||||
* 命中后行为:
|
||||
* - mixin 内部不再拉取分享配置(节省一次 HTTP 请求)
|
||||
* - mixin 注入的 onShareAppMessage / onShareTimeline / $mpShare_* 工具方法仍然存在,
|
||||
* 但内部立即调用 wx.hideShareMenu() 把转发按钮从右上角菜单中隐藏
|
||||
* - 这两条防线双保险:即使 mixin 钩子被业务侧转发链路误触发,也不会显示分享弹窗
|
||||
*
|
||||
* 备选方案对比(都没选,原因列在下方):
|
||||
* - 在 created 里 this.onShareAppMessage = undefined:
|
||||
* Vue mixin 合并策略会把 undefined 视为"未声明",mixin 的钩子仍然生效 → 不可靠
|
||||
* - mixin 内 return null / 不 return:
|
||||
* 微信会回退到默认 title 转发,按钮仍显示 → 不可靠
|
||||
* - 使用 wx.hideShareMenu(本方案采用):
|
||||
* 微信官方 API,会立即把"转发"按钮从当前页右上角菜单中隐藏,行为最稳
|
||||
*/
|
||||
const _DISABLED_PAGES = new Set([
|
||||
'pages_tool/webview/webview', // webview 容器,转发无意义
|
||||
]);
|
||||
|
||||
const _isCurrentPageDisabled = () => {
|
||||
const page = _safeGetCurrentPage();
|
||||
if (!page) return false;
|
||||
const route = (page.route || (page.$page && page.$page.path) || '').replace(/^\//, '');
|
||||
return _DISABLED_PAGES.has(route);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主动隐藏当前页的"转发"按钮
|
||||
* - 只能在 mp-weixin 环境调用(其它平台 wx 全局不存在)
|
||||
* - 必须在 onLoad 之后调用(需要 Page 实例已注册完成)
|
||||
* - 失败时静默吞掉(权限/版本/非微信环境都可能导致失败)
|
||||
*/
|
||||
const _hideShareMenu = () => {
|
||||
try {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== 'undefined' && typeof wx.hideShareMenu === 'function') {
|
||||
wx.hideShareMenu({
|
||||
menus: ['shareAppMessage', 'shareTimeline']
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
} catch (e) {
|
||||
// 静默:旧基础库可能不支持 menus 参数,降级到无参版本
|
||||
try {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== 'undefined' && typeof wx.hideShareMenu === 'function') {
|
||||
wx.hideShareMenu();
|
||||
}
|
||||
// #endif
|
||||
} catch (e2) { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const _getCurrentPath = (withQuery = true) => {
|
||||
const page = _safeGetCurrentPage();
|
||||
if (!page) return '';
|
||||
// page.route 在 mp-weixin 上不带前导斜杠、不带 query;page.options 携带 query
|
||||
const route = page.route || (page.$page && page.$page.path) || '';
|
||||
const options = page.options || {};
|
||||
if (!withQuery) return route ? '/' + route.replace(/^\//, '') : '';
|
||||
const query = Object.keys(options)
|
||||
.filter(k => options[k] !== undefined && options[k] !== null && options[k] !== '')
|
||||
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`)
|
||||
.join('&');
|
||||
return query ? `/${route}?${query}` : `/${route}`;
|
||||
};
|
||||
|
||||
// 模块级缓存:避免重复请求后端
|
||||
let _shareCache = null;
|
||||
let _shareCacheAt = 0;
|
||||
const SHARE_CACHE_TTL = 60 * 1000; // 60s
|
||||
|
||||
const _fetchShareFromServer = (path) => {
|
||||
const Http = require('./http.js').default || require('./http.js');
|
||||
return new Promise((resolve) => {
|
||||
Http.sendRequest({
|
||||
url: '/weapp/api/weapp/share',
|
||||
data: { path },
|
||||
success: (res) => {
|
||||
if (res && res.code >= 0 && res.data && res.data.data) {
|
||||
resolve(res.data.data);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
fail: () => resolve(null)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const _buildFallback = (path) => ({
|
||||
title: '小程序分享',
|
||||
path,
|
||||
imageUrl: ''
|
||||
});
|
||||
|
||||
const _getShareConfig = async (path) => {
|
||||
const now = Date.now();
|
||||
if (_shareCache && (now - _shareCacheAt) < SHARE_CACHE_TTL) {
|
||||
return _shareCache;
|
||||
}
|
||||
const cfg = await _fetchShareFromServer(path);
|
||||
_shareCache = cfg || _buildFallback(path);
|
||||
_shareCacheAt = now;
|
||||
return _shareCache;
|
||||
};
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
__mpShareCache: null, // 当前页面的分享配置
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 在实例创建阶段检查当前页面是否在 _DISABLED_PAGES 列表中。
|
||||
* 命中后调用 wx.hideShareMenu 把当前页的"转发"按钮从右上角菜单中隐藏。
|
||||
*/
|
||||
created() {
|
||||
try {
|
||||
if (_isCurrentPageDisabled()) {
|
||||
// 立即隐藏(此时 wx 还没完全就绪,先尝试一次;失败也无所谓,onLoad 会再调一次)
|
||||
_hideShareMenu();
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
onLoad() {
|
||||
try {
|
||||
if (_isCurrentPageDisabled()) {
|
||||
// 命中关闭列表的页面:
|
||||
// 1) 再次调 wx.hideShareMenu(此时 Page 已就绪,稳定生效)
|
||||
// 2) 不再拉取分享配置,避免无谓请求
|
||||
_hideShareMenu();
|
||||
return;
|
||||
}
|
||||
const path = _getCurrentPath(true);
|
||||
_getShareConfig(path).then(cfg => {
|
||||
// 后端未返回配置时,注入站点名作为默认标题
|
||||
if (cfg && !cfg.title) {
|
||||
const siteName = (this.$store && this.$store.state && this.$store.state.siteInfo && this.$store.state.siteInfo.site_name) || '';
|
||||
cfg.title = siteName || '小程序分享';
|
||||
}
|
||||
this.__mpShareCache = cfg;
|
||||
});
|
||||
} catch (e) {}
|
||||
},
|
||||
onShow() {
|
||||
// 兜底:进入页面后如果尚未就绪,再异步拉一次
|
||||
if (this.__mpShareCache) return;
|
||||
if (_isCurrentPageDisabled()) return; // 命中关闭列表,不再拉
|
||||
try {
|
||||
const path = _getCurrentPath(true);
|
||||
_getShareConfig(path).then(cfg => {
|
||||
this.__mpShareCache = cfg;
|
||||
});
|
||||
} catch (e) {}
|
||||
},
|
||||
/**
|
||||
* 全局默认 onShareAppMessage:
|
||||
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
|
||||
*/
|
||||
onShareAppMessage() {
|
||||
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
|
||||
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
|
||||
// (主防线是 wx.hideShareMenu,这里只是兜底)
|
||||
if (_isCurrentPageDisabled()) {
|
||||
return undefined;
|
||||
}
|
||||
const cfg = this.__mpShareCache;
|
||||
if (cfg && cfg.title) {
|
||||
return {
|
||||
title: cfg.title,
|
||||
path: cfg.path,
|
||||
imageUrl: cfg.imageUrl || ''
|
||||
};
|
||||
}
|
||||
// 后端无配置时:使用站点名 + 当前路径
|
||||
return {
|
||||
title: _buildFallback(_getCurrentPath(true)).title,
|
||||
path: _getCurrentPath(true)
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 全局默认 onShareTimeline(朋友圈):
|
||||
* 朋友圈只支持 query(不能直接传 path),从 cfg.path 中抽取 ? 之后的 query。
|
||||
*/
|
||||
onShareTimeline() {
|
||||
if (_isCurrentPageDisabled()) {
|
||||
return undefined;
|
||||
}
|
||||
const cfg = this.__mpShareCache;
|
||||
if (cfg && cfg.title) {
|
||||
const path = cfg.path || _getCurrentPath(true);
|
||||
const query = path.indexOf('?') > 0 ? path.split('?')[1] : path;
|
||||
return {
|
||||
title: cfg.title,
|
||||
query,
|
||||
imageUrl: cfg.imageUrl || ''
|
||||
};
|
||||
}
|
||||
const path = _getCurrentPath(true);
|
||||
const query = path.indexOf('?') > 0 ? path.split('?')[1] : '';
|
||||
return {
|
||||
title: _buildFallback(path).title,
|
||||
query
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 获取当前小程序页面路径(含 query)
|
||||
*/
|
||||
$mpShare_currentPath() {
|
||||
return _getCurrentPath(true);
|
||||
},
|
||||
/**
|
||||
* 刷新当前页分享配置(用于页面参数变更后想重置转发 path 的场景)
|
||||
*/
|
||||
$mpShare_refresh() {
|
||||
_shareCache = null;
|
||||
_shareCacheAt = 0;
|
||||
return _getShareConfig(_getCurrentPath(true)).then(cfg => {
|
||||
this.__mpShareCache = cfg;
|
||||
return cfg;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 复制当前页面小程序路径到剪贴板
|
||||
* 微信小程序没有"页面 URL"概念,仅可复制形如:
|
||||
* pages/goods/detail?id=123&source_member=xx
|
||||
* 的小程序内部路径,便于分享者在聊天里粘贴。
|
||||
* 优先复用 util.copyCurrentPath。
|
||||
*/
|
||||
$mpShare_copyPath() {
|
||||
const path = _getCurrentPath(true);
|
||||
const text = path.replace(/^\//, ''); // 去掉前导斜杠
|
||||
if (this.$util && typeof this.$util.copyCurrentPath === 'function') {
|
||||
return this.$util.copyCurrentPath({});
|
||||
}
|
||||
// util.copyCurrentPath 不存在(极端情况:util 模块未加载)时直接调 uni API
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => resolve(text),
|
||||
fail: (err) => reject(err)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// #endif
|
||||
@@ -1,6 +1,4 @@
|
||||
import { EventSafety } from '@/common/js/event-safety.js'
|
||||
import util from '@/common/js/util.js'
|
||||
import Lang from '@/common/js/lang.js'
|
||||
import { EventSafety } from './event-safety'
|
||||
|
||||
export class NavigationHelper {
|
||||
constructor() {
|
||||
@@ -44,6 +42,7 @@ export class NavigationHelper {
|
||||
|
||||
query.select('.uni-page-head').boundingClientRect((rect) => {
|
||||
if (rect && rect.height > 0) {
|
||||
console.log('直接查询导航栏高度成功:', rect.height)
|
||||
resolve(rect.height)
|
||||
} else {
|
||||
console.warn('未找到 uni-page-head 元素或高度为0')
|
||||
@@ -60,10 +59,11 @@ export class NavigationHelper {
|
||||
// 微信小程序精确计算
|
||||
try {
|
||||
const menuButtonInfo = wx.getMenuButtonBoundingClientRect()
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
|
||||
const height = menuButtonInfo.bottom +
|
||||
(menuButtonInfo.top - systemInfo.statusBarHeight)
|
||||
console.log('微信小程序导航栏高度:', height)
|
||||
resolve(height)
|
||||
} catch (error) {
|
||||
console.error('微信小程序高度计算失败:', error)
|
||||
@@ -119,7 +119,7 @@ export class NavigationHelper {
|
||||
// 获取状态栏高度
|
||||
getStatusBarHeight() {
|
||||
// #ifdef MP-WEIXIN
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
return systemInfo.statusBarHeight || 20
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
@@ -138,7 +138,7 @@ export class NavigationHelper {
|
||||
// 获取安全区域
|
||||
getSafeAreaInsets() {
|
||||
try {
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
return systemInfo.safeArea || {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
@@ -173,6 +173,7 @@ export class NavigationHelper {
|
||||
|
||||
// 处理服务请求
|
||||
async handleServiceRequest(event, component) {
|
||||
console.log('处理导航相关服务请求:', event.type)
|
||||
|
||||
// 安全检查事件目标
|
||||
if (this.shouldProcessNavigationRequest(event)) {
|
||||
@@ -261,7 +262,7 @@ export class NavigationHelper {
|
||||
}
|
||||
|
||||
// 显示用户友好的错误信息
|
||||
this.showError(Lang.lang('common.navigation.serviceUnavailable'))
|
||||
this.showError('导航服务暂时不可用')
|
||||
}
|
||||
|
||||
// 显示错误提示
|
||||
@@ -276,6 +277,7 @@ export class NavigationHelper {
|
||||
// 清理缓存
|
||||
clearCache() {
|
||||
this.navigationCache.clear()
|
||||
console.log('导航缓存已清理')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* 统一平台检测模块
|
||||
* 消除分散在 http.js, util.js, event-bus.js, customer-service.js 中的重复平台检测代码
|
||||
*/
|
||||
|
||||
let cachedPlatform = null;
|
||||
let cachedAppType = null;
|
||||
let cachedAppTypeName = null;
|
||||
|
||||
// 延迟加载 lang 模块,避免与 lang.js 形成循环依赖
|
||||
// (lang.js 顶部 import 了 Platform,所以 platform.js 不能顶部 import lang)
|
||||
function _getLang() {
|
||||
try {
|
||||
return require('@/common/js/lang.js').default;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _t(key, fallback) {
|
||||
const lang = _getLang();
|
||||
if (lang && typeof lang.lang === 'function') {
|
||||
const result = lang.lang(key);
|
||||
// lang() 在找不到 key 时会返回 key 本身(开发环境)或空串(生产环境)
|
||||
if (result && result !== key) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const Platform = {
|
||||
/**
|
||||
* 检测当前运行平台
|
||||
* @returns {string} 平台标识
|
||||
*/
|
||||
detect() {
|
||||
if (cachedPlatform) {
|
||||
return cachedPlatform;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
cachedPlatform = 'h5';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
cachedPlatform = 'weapp';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-ALIPAY
|
||||
cachedPlatform = 'alipay';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-BAIDU
|
||||
cachedPlatform = 'baidu';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-TOUTIAO
|
||||
cachedPlatform = 'toutiao';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-QQ
|
||||
cachedPlatform = 'qq';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-KUAISHOU
|
||||
cachedPlatform = 'kuaishou';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
cachedPlatform = 'harmony';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-JD
|
||||
cachedPlatform = 'jd';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-360
|
||||
cachedPlatform = '360';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-XHS
|
||||
cachedPlatform = 'xhs';
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
cachedPlatform = 'app';
|
||||
// #endif
|
||||
|
||||
// #ifdef PC
|
||||
cachedPlatform = 'pc';
|
||||
// #endif
|
||||
|
||||
if (!cachedPlatform) {
|
||||
cachedPlatform = 'unknown';
|
||||
}
|
||||
|
||||
return cachedPlatform;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取平台类型标识 (用于API请求)
|
||||
* @returns {string} app_type 值
|
||||
*/
|
||||
getAppType() {
|
||||
if (cachedAppType) {
|
||||
return cachedAppType;
|
||||
}
|
||||
|
||||
const platform = this.detect();
|
||||
|
||||
// #ifdef H5
|
||||
if (platform === 'h5') {
|
||||
cachedAppType = this.isWeiXin() ? 'wechat' : 'h5';
|
||||
return cachedAppType;
|
||||
}
|
||||
// #endif
|
||||
|
||||
const platformMap = {
|
||||
weapp: 'weapp',
|
||||
alipay: 'aliapp',
|
||||
baidu: 'baiduapp',
|
||||
toutiao: 'MP-TOUTIAO',
|
||||
qq: 'MP-QQ',
|
||||
kuaishou: 'kuaishou',
|
||||
jd: 'jd',
|
||||
'360': '360',
|
||||
xhs: 'xhs',
|
||||
harmony: 'harmony',
|
||||
app: 'app',
|
||||
pc: 'pc'
|
||||
};
|
||||
|
||||
cachedAppType = platformMap[platform] || 'unknown';
|
||||
return cachedAppType;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取平台类型名称 (用于显示)
|
||||
* @returns {string} app_type_name 值
|
||||
*/
|
||||
getAppTypeName() {
|
||||
if (cachedAppTypeName) {
|
||||
return cachedAppTypeName;
|
||||
}
|
||||
|
||||
const platform = this.detect();
|
||||
|
||||
// #ifdef H5
|
||||
if (platform === 'h5') {
|
||||
cachedAppTypeName = this.isWeiXin()
|
||||
? _t('common.platform.wechatOfficialAccount', '微信公众号')
|
||||
: _t('common.platform.h5', 'H5');
|
||||
return cachedAppTypeName;
|
||||
}
|
||||
// #endif
|
||||
|
||||
const platformNameMap = {
|
||||
weapp: _t('common.platform.wechatMiniProgram', '微信小程序'),
|
||||
alipay: _t('common.platform.alipayMiniProgram', '支付宝小程序'),
|
||||
baidu: _t('common.platform.baiduMiniProgram', '百度小程序'),
|
||||
toutiao: _t('common.platform.toutiaoMiniProgram', '头条小程序'),
|
||||
qq: _t('common.platform.qqMiniProgram', 'QQ小程序'),
|
||||
kuaishou: _t('common.platform.kuaishouMiniProgram', '快手小程序'),
|
||||
jd: _t('common.platform.jdMiniProgram', '京东小程序'),
|
||||
'360': _t('common.platform.miniProgram360', '360小程序'),
|
||||
xhs: _t('common.platform.xhsMiniProgram', '小红书小程序'),
|
||||
harmony: _t('common.platform.harmonyService', '鸿蒙元服务'),
|
||||
app: _t('common.platform.app', 'APP'),
|
||||
pc: _t('common.platform.pc', 'PC端'),
|
||||
};
|
||||
|
||||
cachedAppTypeName = platformNameMap[platform] || _t('common.platform.unknown', '未知平台');
|
||||
return cachedAppTypeName;
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断是否运行在微信浏览器中 (H5环境)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isWeiXin() {
|
||||
// #ifdef H5
|
||||
return navigator.userAgent.toLowerCase().includes('micromessenger');
|
||||
// #endif
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断是否是小程序环境
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMiniProgram() {
|
||||
const platform = this.detect();
|
||||
const miniProgramPlatforms = ['weapp', 'alipay', 'baidu', 'toutiao', 'qq', 'kuaishou', 'jd', '360', 'xhs', 'harmony'];
|
||||
return miniProgramPlatforms.includes(platform);
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断是否是移动端 (小程序或H5或APP)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMobile() {
|
||||
const platform = this.detect();
|
||||
return platform !== 'pc' && platform !== 'unknown';
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的平台信息对象
|
||||
* @returns {Object} 包含 platform, appType, appTypeName
|
||||
*/
|
||||
getPlatformInfo() {
|
||||
return {
|
||||
platform: this.detect(),
|
||||
appType: this.getAppType(),
|
||||
appTypeName: this.getAppTypeName()
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除缓存 (用于测试或需要重新检测的场景)
|
||||
*/
|
||||
clearCache() {
|
||||
cachedPlatform = null;
|
||||
cachedAppType = null;
|
||||
cachedAppTypeName = null;
|
||||
}
|
||||
};
|
||||
|
||||
export default Platform;
|
||||
@@ -1,978 +0,0 @@
|
||||
/**
|
||||
* 全局隐私协议管理器(单例)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 统一判断是否需要弹窗(storage + 平台级隐私状态)
|
||||
* 2. 通过 uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件弹窗
|
||||
* 3. 用户同意后写 storage,拒绝后不写(允许下次再弹)
|
||||
* 4. 只弹一次:同意后永不再弹
|
||||
* 5. 事件补偿:若 emit 时无监听者,保留 _pendingShow,页面 mounted 时补偿弹窗
|
||||
*
|
||||
* 平台差异:
|
||||
* H5: 仅查 storage
|
||||
* 微信小程序: wx.getPrivacySetting() 查平台级隐私状态
|
||||
* 鸿蒙元服务: has.getPrivacySetting() 查平台级隐私状态
|
||||
*
|
||||
* 事件:
|
||||
* uni.$emit('privacy:show') — 通知当前页面组件弹窗
|
||||
* uni.$emit('privacy:agreed') — 用户已同意
|
||||
* uni.$emit('privacy:disagreed') — 用户已拒绝
|
||||
* uni.$emit('privacy:closeOthers') — 通知其他页面关闭弹窗
|
||||
* uni.$emit('privacy:rules-updated') — URL/路由规则被应用/融合后触发 (远程下发后热更新)
|
||||
*
|
||||
* 使用:
|
||||
* App.vue onLaunch (boot完成后): await PrivacyManager.show()
|
||||
* privacy-popup.vue: mounted 中 uni.$on('privacy:show') + popPendingShow() 补偿
|
||||
* privacy-popup.vue: handleAgree 中 PrivacyManager.agree()
|
||||
* privacy-popup.vue: handleDisagree 中 PrivacyManager.disagree()
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'privacyAgreed';
|
||||
|
||||
// 内部状态
|
||||
let _needShow = false; // 是否需要弹窗
|
||||
let _hasChecked = false; // 是否已执行过 check
|
||||
let _pendingShow = false; // emit 时无监听者的补偿标志
|
||||
// URL/路由规则已经做过决策 (避免鸿蒙兜底在白名单模式下错误弹窗)
|
||||
let _urlRuleDecided = false;
|
||||
|
||||
// ============================================================
|
||||
// 弹窗"实际显示"状态 (由 privacy-popup 通过 privacy:popup-state 事件同步)
|
||||
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
|
||||
// 双阶段弹窗重评估要用这个真实状态判断
|
||||
let _popupActuallyShowing = false
|
||||
|
||||
// ============================================================
|
||||
/**
|
||||
* 同步读取 storage + 平台决策 (H5 + 不依赖 storage 的情况)
|
||||
* 用于"已同意过"的快速路径, 不走 async checkPrivacy
|
||||
* @returns {{needAuthorization: boolean, fromCache: boolean}}
|
||||
*/
|
||||
function checkPrivacySync() {
|
||||
// H5 + 其他非微信/快应用/鸿蒙平台: storage 决定一切
|
||||
const storageAgreed = getStorageAgreed()
|
||||
return {
|
||||
needAuthorization: !storageAgreed,
|
||||
fromCache: storageAgreed,
|
||||
}
|
||||
}
|
||||
|
||||
// URL/路由级别隐私弹窗规则
|
||||
// mode:
|
||||
// 'all' - 始终走弹窗检查(默认, 向后兼容)
|
||||
// 'only' - 白名单: 只有命中 patterns 的页面才走弹窗检查, 其他场景一律跳过
|
||||
// 'skip' - 黑名单: 命中 patterns 的页面跳过弹窗, 其他场景走弹窗检查
|
||||
// 匹配方式: 子串匹配
|
||||
// H5: pathname + search + hash
|
||||
// 小程序/鸿蒙: 当前页面 route + query
|
||||
// patterns 为空数组时:
|
||||
// 'all' → 无规则, 走弹窗检查
|
||||
// 'only' → 视为"没有需要弹窗的页面", 一律跳过
|
||||
// 'skip' → 视为"没有需要跳过的页面", 全部走弹窗检查
|
||||
// ============================================================
|
||||
const PRIVACY_URL_RULES = {
|
||||
mode: 'all',
|
||||
patterns: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 storage 中的同意状态
|
||||
*/
|
||||
function getStorageAgreed() {
|
||||
try {
|
||||
return !!uni.getStorageSync(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序: 查询平台级隐私设置
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
*/
|
||||
function getWxPrivacySetting() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
if (wx.getPrivacySetting) {
|
||||
wx.getPrivacySetting({
|
||||
success: (res) => {
|
||||
resolve({
|
||||
needAuthorization: !!res.needAuthorization,
|
||||
privacyContractName: res.privacyContractName || ''
|
||||
});
|
||||
},
|
||||
fail: () => resolve({ needAuthorization: !getStorageAgreed() })
|
||||
});
|
||||
} else {
|
||||
// 低版本基础库,回退到 storage
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
}
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 快应用: 查询平台级隐私设置
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
*/
|
||||
function getQuickAppPrivacySetting() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
try {
|
||||
const has = require('@system.has');
|
||||
if (has && has.getPrivacySetting) {
|
||||
has.getPrivacySetting({
|
||||
success: (res) => {
|
||||
resolve({
|
||||
needAuthorization: !!res.needAuthorization,
|
||||
privacyContractName: res.privacyContractName || ''
|
||||
});
|
||||
},
|
||||
fail: () => resolve({ needAuthorization: !getStorageAgreed() })
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
// 非快应用环境: 仅查 storage
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 鸿蒙元服务: 查询平台级隐私设置
|
||||
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
*/
|
||||
function getHarmonyPrivacySetting() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
|
||||
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
// #ifndef MP-HARMONY
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合查询: 平台级隐私状态 + storage 状态
|
||||
* 任一判断为"需要授权"则弹窗
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
*/
|
||||
async function checkPrivacy() {
|
||||
// 微信小程序: 调 wx.getPrivacySetting
|
||||
// 注意: uniapp 不支持 #ifndef 多 || 与嵌套 #ifndef,
|
||||
// 改用回退策略 - 仅微信走异步 API, 其他平台走 sync checkPrivacySync()
|
||||
// 调用方 (show) 已经对 storage 已同意做了 sync 短路 (参见 checkPrivacySync()),
|
||||
// 这里只处理 storage 未同意情况下的 async API 调用。
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof wx !== 'undefined' && wx.getPrivacySetting) {
|
||||
return await getWxPrivacySetting();
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 快应用: 调 has.getPrivacySetting
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
if (typeof has !== 'undefined' && has.getPrivacySetting) {
|
||||
return await getQuickAppPrivacySetting();
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
||||
// H5 / 其他平台: 仅查 storage
|
||||
return checkPrivacySync();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前页面的"路径字符串"
|
||||
* H5: location.pathname + search + hash
|
||||
* 微信小程序: 当前页面 route + query (例: pages/index/index?id=1)
|
||||
* 鸿蒙元服务: 当前页面 route + query (同小程序)
|
||||
* @param {Object} [fallback] - 兜底路径, 用于 onLaunch 等还没 getCurrentPages 的时机
|
||||
* H5: { pathname, search, hash }
|
||||
* 小程序/鸿蒙: { route, query }
|
||||
* @returns {string}
|
||||
*/
|
||||
function getCurrentPathString(fallback) {
|
||||
// H5
|
||||
// #ifdef H5
|
||||
// H5 模式下优先用 getCurrentPages() 拿 uniapp route (history/hash 都适配)
|
||||
// 兜底: window.location.pathname + search + hash
|
||||
try {
|
||||
if (typeof getCurrentPages === 'function') {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1]
|
||||
if (cur && cur.route) {
|
||||
const route = cur.route
|
||||
const query = cur.options
|
||||
let queryStr = ''
|
||||
if (query && typeof query === 'object' && Object.keys(query).length) {
|
||||
queryStr = '?' + Object.keys(query)
|
||||
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
|
||||
.map(k => k + '=' + encodeURIComponent(query[k]))
|
||||
.join('&')
|
||||
}
|
||||
return '/' + String(route).replace(/^\/+/, '') + queryStr
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore, fall through to location */ }
|
||||
// 兜底: window.location (含 history 路由 + base)
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const pathname = (fallback && fallback.pathname) || window.location.pathname || ''
|
||||
const search = (fallback && fallback.search !== undefined ? fallback.search : window.location.search) || ''
|
||||
const hash = (fallback && fallback.hash !== undefined ? fallback.hash : window.location.hash) || ''
|
||||
return pathname + search + hash
|
||||
}
|
||||
return ''
|
||||
// #endif
|
||||
|
||||
// 微信小程序 / 鸿蒙元服务 / 其他支持 getCurrentPages 的端
|
||||
// #ifndef H5
|
||||
try {
|
||||
const pages = (typeof getCurrentPages === 'function') ? getCurrentPages() : []
|
||||
let route = ''
|
||||
let query = null
|
||||
const cur = pages[pages.length - 1]
|
||||
if (cur) {
|
||||
route = cur.route || ''
|
||||
query = cur.options
|
||||
}
|
||||
// 兜底: onLaunch 时 getCurrentPages 还没页面, 用 fallback
|
||||
if (!route && fallback) {
|
||||
route = fallback.route || ''
|
||||
query = fallback.query
|
||||
}
|
||||
if (!route) return ''
|
||||
let queryStr = ''
|
||||
if (query && typeof query === 'object' && Object.keys(query).length) {
|
||||
queryStr = '?' + Object.keys(query)
|
||||
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
|
||||
.map(k => k + '=' + encodeURIComponent(query[k]))
|
||||
.join('&')
|
||||
}
|
||||
return '/' + String(route).replace(/^\/+/, '') + queryStr
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 path 是否命中 patterns 之一
|
||||
* 匹配规则 (双通道, 任一命中即算命中):
|
||||
* 1. 子串匹配: path.indexOf(p) !== -1 (兼容 /pages/index/index 这种完整路径)
|
||||
* 2. 结尾匹配: path 以 p 结尾 (兼容 subPackage 路径 /pages_tool/member/index 命中 "member/index")
|
||||
* 这样业务侧既可以写完整路径也可以写相对路径
|
||||
*/
|
||||
function matchAny(path, patterns) {
|
||||
if (!path || !Array.isArray(patterns)) return false
|
||||
return patterns.some((p) => {
|
||||
if (!p || typeof p !== 'string') return false
|
||||
return path.indexOf(p) !== -1 || path.endsWith(p)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 PRIVACY_URL_RULES 判断当前 URL/路由是否要走弹窗检查
|
||||
* @param {Object} [fallback] - 传给 getCurrentPathString 的兜底路径
|
||||
* @returns {boolean} true=本次应走弹窗检查, false=本次跳过
|
||||
*/
|
||||
function shouldShowByUrlRule(fallback) {
|
||||
const mode = PRIVACY_URL_RULES.mode || 'all'
|
||||
const patterns = PRIVACY_URL_RULES.patterns || []
|
||||
if (!Array.isArray(patterns) || !patterns.length) {
|
||||
// 空 patterns 时:
|
||||
// 'all' → 无规则, 走检查
|
||||
// 'only' → 没有需要弹窗的页面, 一律跳过
|
||||
// 'skip' → 没有需要跳过的页面, 全部走检查
|
||||
return mode === 'all' || mode === 'skip'
|
||||
}
|
||||
const path = getCurrentPathString(fallback)
|
||||
const hit = matchAny(path, patterns)
|
||||
if (mode === 'only') return hit
|
||||
if (mode === 'skip') return !hit
|
||||
return true // mode === 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖 URL/路由级别隐私弹窗规则
|
||||
* @param {Object} rules
|
||||
* @param {'all'|'only'|'skip'} [rules.mode='all']
|
||||
* @param {string[]} [rules.patterns=[]]
|
||||
*/
|
||||
function setUrlRules(rules) {
|
||||
if (!rules || typeof rules !== 'object') return
|
||||
if (rules.mode && ['all', 'only', 'skip'].indexOf(rules.mode) !== -1) {
|
||||
PRIVACY_URL_RULES.mode = rules.mode
|
||||
}
|
||||
if (Array.isArray(rules.patterns)) {
|
||||
PRIVACY_URL_RULES.patterns = rules.patterns.slice()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化键 (与 privacyAgreed 同维度, 方便 uniacid 隔离)
|
||||
* 由 App.vue 在 setUrlRules 前注入前缀: privacy_url_rules_<uniacid>
|
||||
*/
|
||||
let _storageKey = 'privacy_url_rules'
|
||||
|
||||
/**
|
||||
* 从 storage 读取上次持久化的最终规则
|
||||
* 自动过滤 __merge/__source/__ts 元信息, 仅返回纯规则
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function loadPersistedRules() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(_storageKey)
|
||||
if (!raw) return null
|
||||
const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw
|
||||
if (!obj || typeof obj !== 'object') return null
|
||||
// 过滤元信息
|
||||
return {
|
||||
mode: obj.mode,
|
||||
patterns: Array.isArray(obj.patterns) ? obj.patterns.slice() : [],
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化当前最终规则
|
||||
* 携带 merge 字段 + 时间戳 + 签名, 方便后续热修复/审计/防篡改
|
||||
* @param {Object} rules
|
||||
* @param {Object} [meta] - { merge?: string, source?: string, sign?: string }
|
||||
*/
|
||||
function persistRules(rules, meta) {
|
||||
try {
|
||||
const final = Object.assign({}, rules || PRIVACY_URL_RULES, {
|
||||
__merge: (meta && meta.merge) || (rules && rules.__merge) || 'replace',
|
||||
__source: (meta && meta.source) || 'base',
|
||||
__ts: Date.now(),
|
||||
__sign: (meta && meta.sign) || (rules && rules.__sign) || '',
|
||||
})
|
||||
uni.setStorageSync(_storageKey, final)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算远程 patch 的签名 (前端简易实现, 与后端 hash_hmac 对齐)
|
||||
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||||
* 前端: 用 btoa 或 uni.arrayBufferToBase64 模拟 (弱校验, 防简单篡改)
|
||||
* 真正的强校验建议用 crypto-js (引入成本)
|
||||
* @param {Object} patch - 远程下发对象
|
||||
* @param {string} secret
|
||||
* @returns {string}
|
||||
*/
|
||||
function computeSign(patch, secret) {
|
||||
if (!secret || !patch) return ''
|
||||
try {
|
||||
const payload = JSON.stringify(patch)
|
||||
let _b64 = ''
|
||||
if (typeof btoa === 'function') {
|
||||
_b64 = btoa(payload)
|
||||
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
const _encoder = (typeof TextEncoder !== 'undefined') ? new TextEncoder() : null
|
||||
if (_encoder) {
|
||||
_b64 = uni.arrayBufferToBase64(_encoder.encode(payload).buffer)
|
||||
}
|
||||
}
|
||||
if (!_b64) return ''
|
||||
// 取前 32 位作为 signature (与 lang-loader.js validateSign 保持一致)
|
||||
return _b64.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验远程 patch
|
||||
* - signSecret 未配置 → 通过 (向后兼容)
|
||||
* - patch.sign 缺失 → 通过 (后端未启用签名)
|
||||
* - patch.sign 不匹配 → 拒绝 (返回 false)
|
||||
* @param {Object} patch - 远程下发 { mode, patterns, merge, sign? }
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyRemotePatch(patch) {
|
||||
if (!patch || typeof patch !== 'object') return false
|
||||
// 业务侧未配置密钥 → 跳过校验
|
||||
if (!_signSecret) return true
|
||||
// patch 未带 sign 字段 → 后端未启用签名, 跳过校验 (兼容旧版本后端)
|
||||
if (!patch.sign) return true
|
||||
const expected = computeSign(patch, _signSecret)
|
||||
const ok = (expected && expected === patch.sign)
|
||||
if (!ok) {
|
||||
console.warn('[privacy] remote patch sign invalid', {
|
||||
expected: expected.slice(0, 8),
|
||||
received: String(patch.sign).slice(0, 8),
|
||||
})
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地 base 与远程 patch
|
||||
* mode:
|
||||
* patch.mode 缺失/非合法值 → 用 base.mode
|
||||
* patch.mode 合法 → 用 patch.mode (远程覆盖)
|
||||
* patterns:
|
||||
* merge = 'replace' → 用 patch.patterns (远程整体替换, 默认)
|
||||
* merge = 'union' → base + patch 去重合并 (子串匹配: 较长的子串已包含较短, 保留较长)
|
||||
* merge = 'intersect'→ 仅保留同时出现在 base 和 patch 的项
|
||||
* merge 缺失/未知 → 退化为 'replace'
|
||||
* patch.patterns 缺失/非数组 → 用 base.patterns
|
||||
*
|
||||
* @param {Object} base - 本地声明的基础规则 { mode, patterns }
|
||||
* @param {Object} patch - 远程下发的补丁 { mode?, patterns?, merge? }
|
||||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||||
*/
|
||||
function mergeRules(base, patch) {
|
||||
const baseMode = (base && base.mode) || 'all'
|
||||
const basePatterns = (base && Array.isArray(base.patterns)) ? base.patterns.slice() : []
|
||||
|
||||
if (!patch || typeof patch !== 'object') {
|
||||
return { mode: baseMode, patterns: basePatterns, merge: 'replace' }
|
||||
}
|
||||
|
||||
// mode: 远程值合法则覆盖
|
||||
let mode = baseMode
|
||||
if (patch.mode && ['all', 'only', 'skip'].indexOf(patch.mode) !== -1) {
|
||||
mode = patch.mode
|
||||
}
|
||||
|
||||
// patterns + merge
|
||||
const merge = (patch.merge && ['replace', 'union', 'intersect'].indexOf(patch.merge) !== -1)
|
||||
? patch.merge
|
||||
: 'replace'
|
||||
|
||||
let patterns
|
||||
if (!Array.isArray(patch.patterns)) {
|
||||
patterns = basePatterns
|
||||
} else if (merge === 'replace') {
|
||||
patterns = patch.patterns.slice()
|
||||
} else if (merge === 'union') {
|
||||
patterns = dedupeUnion(basePatterns, patch.patterns)
|
||||
} else { // intersect
|
||||
patterns = basePatterns.filter(p => patch.patterns.indexOf(p) !== -1)
|
||||
}
|
||||
|
||||
return { mode, patterns, merge }
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重合并 (子串匹配: 若 a 包含 b, b 可视为冗余)
|
||||
* 简单实现: 直接 concat 后去重, 复杂度可接受 (规则通常 < 50 条)
|
||||
*/
|
||||
function dedupeUnion(a, b) {
|
||||
const all = (a || []).concat(b || [])
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const p of all) {
|
||||
if (typeof p !== 'string' || !p) continue
|
||||
if (seen.has(p)) continue
|
||||
seen.add(p)
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用"已合并规则"到 PRIVACY_URL_RULES
|
||||
* 触发 'privacy:rules-updated' 事件, 业务侧可监听 (例如想热更新弹窗)
|
||||
* @param {Object} rules
|
||||
* @param {Object} [meta] - { source: 'base'|'remote'|'persisted', patch?: Object }
|
||||
*/
|
||||
function applyMergedRules(rules, meta) {
|
||||
if (!rules || typeof rules !== 'object') return
|
||||
const prevMode = PRIVACY_URL_RULES.mode
|
||||
const prevPatterns = PRIVACY_URL_RULES.patterns.slice()
|
||||
// 记录"应用前 popup 实际显示状态", 用于 reEvaluate 决策
|
||||
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
|
||||
// 必须用 _popupActuallyShowing (由 privacy-popup 主动同步)
|
||||
const prevPopupShowing = _popupActuallyShowing
|
||||
setUrlRules(rules)
|
||||
persistRules(rules, {
|
||||
merge: rules && rules.merge,
|
||||
source: meta && meta.source,
|
||||
sign: meta && meta.patch && meta.patch.sign,
|
||||
})
|
||||
try {
|
||||
uni.$emit && uni.$emit('privacy:rules-updated', {
|
||||
mode: PRIVACY_URL_RULES.mode,
|
||||
patterns: PRIVACY_URL_RULES.patterns.slice(),
|
||||
source: (meta && meta.source) || 'remote',
|
||||
patch: meta && meta.patch,
|
||||
})
|
||||
} catch (e) {}
|
||||
|
||||
// 双阶段弹窗: 远程 patch 在 show() 之后到达时, 强制重评估
|
||||
// 触发条件:
|
||||
// 1. patch 来源是 'remote' (非 'base')
|
||||
// 2. 规则真的变了 (mode/patterns 之一不同)
|
||||
// 3. show() 已经调用过 (_hasChecked=true)
|
||||
// 行为:
|
||||
// - 旧规则没弹 → 新规则要弹 → emit show (popup 重弹)
|
||||
// - 旧规则在弹 → 新规则不该弹 → emit dismiss (popup 关闭)
|
||||
const _source = (meta && meta.source) || 'remote'
|
||||
const _rulesChanged = (
|
||||
prevMode !== PRIVACY_URL_RULES.mode ||
|
||||
prevPatterns.length !== PRIVACY_URL_RULES.patterns.length ||
|
||||
prevPatterns.some((p, i) => p !== PRIVACY_URL_RULES.patterns[i])
|
||||
)
|
||||
if (_source === 'remote' && _rulesChanged && _hasChecked) {
|
||||
// dev-only 日志, 受 __PRIVACY_DEBUG__ 开关控制 (默认开, 生产可通过 DefinePlugin 关)
|
||||
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
|
||||
console.log('[privacy] reEvaluate trigger: prevMode=' + prevMode + ' newMode=' + PRIVACY_URL_RULES.mode + ' prevPopupShowing=' + prevPopupShowing)
|
||||
}
|
||||
// 异步执行, 不阻塞 apply 流程
|
||||
setTimeout(() => { reEvaluateAfterRulesUpdated(prevPopupShowing) }, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则更新后的重评估
|
||||
* 场景: 远程 patch 在 show() 之后到达, 本次启动弹窗决策是基于旧规则做的
|
||||
* 现在新规则来了, 必须重评估
|
||||
* 步骤:
|
||||
* 1. 用 prevPopupShowing (popup 实际显示状态) + shouldShowByUrlRule (应用后规则 + 当前 URL) 算差量
|
||||
* 2. prevPopupShowing=false, shouldShow=true → emit show (popup 重弹)
|
||||
* 3. prevPopupShowing=true, shouldShow=false → emit dismiss (popup 关闭)
|
||||
* 4. 其他情况: 状态不变, 不 emit (避免无谓的弹窗抖动)
|
||||
* 边界: 用户已同意过 storage → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
|
||||
* @param {boolean} prevPopupShowing - 应用规则前的 popup 实际显示状态
|
||||
*/
|
||||
function reEvaluateAfterRulesUpdated(prevPopupShowing) {
|
||||
// dev-only 日志
|
||||
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
|
||||
console.log('[privacy] reEvaluate called prevPopup=' + prevPopupShowing + ' storageAgreed=' + getStorageAgreed())
|
||||
}
|
||||
if (getStorageAgreed()) {
|
||||
// 已同意过 → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
|
||||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'noop', reason: 'already-agreed' }) } catch (e) {}
|
||||
return
|
||||
}
|
||||
// 用户未同意 → 走规则决策
|
||||
const shouldShow = shouldShowByUrlRule()
|
||||
const wasShowing = !!prevPopupShowing
|
||||
if (!wasShowing && shouldShow) {
|
||||
// 旧规则没弹, 新规则要弹 → 重新设置 _pendingShow + emit show
|
||||
_needShow = true
|
||||
_pendingShow = true
|
||||
_hasChecked = true
|
||||
_urlRuleDecided = false
|
||||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'show', reason: 'rules-updated' }) } catch (e) {}
|
||||
} else if (wasShowing && !shouldShow) {
|
||||
// 旧规则在弹, 新规则不该弹 → 撤销 _pendingShow + emit dismiss
|
||||
_needShow = false
|
||||
_pendingShow = false
|
||||
_hasChecked = true
|
||||
_urlRuleDecided = true
|
||||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'dismiss', reason: 'rules-updated' }) } catch (e) {}
|
||||
}
|
||||
// 其他情况: 状态不变, 不 emit
|
||||
}
|
||||
|
||||
/**
|
||||
* 一站式: 应用本地 base → 若有远程 patch 则融合 → 持久化 → 触发事件
|
||||
* 无远程 patch 时, base 直接生效 (向后兼容)
|
||||
* @param {Object} base - 本地基础规则
|
||||
* @param {Object} [patch] - 远程下发的 patch (可空)
|
||||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||||
*/
|
||||
function applyWithMerge(base, patch) {
|
||||
// 签名校验: 远程 patch 不合法 → 静默拒绝, 退回 base
|
||||
// (业务侧可通过 PrivacyManager.verifyRemotePatch() 单独调用)
|
||||
if (patch && !verifyRemotePatch(patch)) {
|
||||
console.warn('[privacy] applyWithMerge: remote patch sign invalid, fallback to base')
|
||||
const merged = mergeRules(base, null)
|
||||
applyMergedRules(merged, { source: 'base', patch: null })
|
||||
return merged
|
||||
}
|
||||
const merged = mergeRules(base, patch)
|
||||
applyMergedRules(merged, { source: patch ? 'remote' : 'base', patch })
|
||||
return merged
|
||||
}
|
||||
|
||||
// 本地 base 规则 (由 App.vue 通过 setBaseRules 登记)
|
||||
// 供 store/index.js 的 init action 在收到远程 patch 时读取
|
||||
let _baseRules = { mode: 'all', patterns: [] }
|
||||
|
||||
// 外部注入的 readyWaiters (由 config-external.js ConfigExternal 持有)
|
||||
// 兼容老用法: 业务侧可直接传 readyGuard, 也可走 ConfigExternal.setReadyWaiters 通道
|
||||
let _externalReadyGuard = null
|
||||
let _externalBootTimeout = 6000
|
||||
let _externalWaitChannels = { lang: true, theme: true, themeOverride: true, remoteConfig: true }
|
||||
|
||||
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||||
// 业务侧通过 setSignSecret(secret) 或 privacyConfig.signSecret 注入
|
||||
let _signSecret = ''
|
||||
|
||||
// 远程 patch 签名 (在持久化时被写入 storage 的 __sign 字段, 供后续审计/防回滚)
|
||||
// 注意: 当前实现不做增量校验, 只在 verifyRemotePatch 内单次校验
|
||||
// 如需增量校验 (防止回滚到旧 patch), 可在此维护 _lastPersistedSign
|
||||
// 每次 applyWithMerge 持久化时记录, 下次启动时与远程 patch 的 sign 字段比对
|
||||
let _lastPersistedSign = ''
|
||||
|
||||
// ============================================================
|
||||
// 监听 privacy:popup-state 事件 (由 privacy-popup.vue 主动同步弹窗显示状态)
|
||||
// 用于双阶段弹窗的精确重评估 (reEvaluateAfterRulesUpdated)
|
||||
// 这里挂一个全局监听, 模块加载时就生效
|
||||
// ============================================================
|
||||
;(function _installPopupStateListener() {
|
||||
try {
|
||||
if (typeof uni === 'undefined' || !uni.$on) return
|
||||
uni.$on('privacy:popup-state', (info) => {
|
||||
if (info && typeof info.showing === 'boolean') {
|
||||
_popupActuallyShowing = info.showing
|
||||
}
|
||||
})
|
||||
} catch (e) {}
|
||||
})()
|
||||
|
||||
const PrivacyManager = {
|
||||
/**
|
||||
* 同步检查 storage 中的同意状态(快速判断,不查平台)
|
||||
* @returns {boolean} true=需要弹窗, false=不需要
|
||||
*/
|
||||
check() {
|
||||
_needShow = !getStorageAgreed();
|
||||
_hasChecked = true;
|
||||
return _needShow;
|
||||
},
|
||||
|
||||
/**
|
||||
* 同步标记本次跳过弹窗(不查 storage、不写 storage、不弹窗)
|
||||
* 必须在 page mounted 之前调用,避免 onLaunch await 让出事件循环后
|
||||
* privacy-popup.vue 的 consumePendingShow 走兜底分支把弹窗弹出来
|
||||
* 用于 H5 浏览器直访特定 URL(子串匹配)时不让隐私弹窗挡住
|
||||
*/
|
||||
markSkipped() {
|
||||
_needShow = false;
|
||||
_hasChecked = true;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
|
||||
},
|
||||
|
||||
/**
|
||||
* 触发弹窗(如果需要)
|
||||
* 由 App.vue onLaunch 在 boot 完成后调用
|
||||
* 异步查询平台隐私状态,设置 _pendingShow 标志
|
||||
* 页面组件 mounted 时通过 consumePendingShow() 消费标志并弹窗
|
||||
* 不直接 emit 事件,避免 emit + 补偿双触发
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.skip=false] true=本次跳过弹窗(不写 storage,下次正常访问其他页面还会弹)
|
||||
* @param {Object} [options.urlFallback] 首次启动 onLaunch 时 getCurrentPages 还没有页面,
|
||||
* 可传入 onLaunch options 兜底:
|
||||
* H5: { pathname, search, hash }
|
||||
* 小程序/鸿蒙: { route, query }
|
||||
* @param {Promise|Function} [options.readyGuard] 外部 readiness 守卫 (例如 i18n/theme),
|
||||
* show() 会 await 它再继续, 保证弹窗在外部资源就绪后才决策.
|
||||
* 超时保护: 业务侧应保证 guard 自身有超时, 否则会一直 hang.
|
||||
*/
|
||||
async show(options = {}) {
|
||||
const fallback = options && options.urlFallback ? options.urlFallback : null
|
||||
|
||||
// 外部 readiness 守卫: 等业务侧 promise 再继续
|
||||
// 优先级: options.readyGuard (调用方显式传) > _externalReadyGuard (ConfigExternal 注入)
|
||||
// 场景: App.vue onLaunch 把 i18n/theme/remote-config 的 await 链作为 guard 传入
|
||||
// 这样无论 show() 在哪里调用, 都保证弹窗决策时外部资源已就绪
|
||||
const _guard = (options && typeof options.readyGuard !== 'undefined')
|
||||
? options.readyGuard
|
||||
: _externalReadyGuard
|
||||
if (typeof _guard !== 'undefined' && _guard !== null) {
|
||||
try {
|
||||
if (_guard && typeof _guard.then === 'function') {
|
||||
await _guard
|
||||
} else if (typeof _guard === 'function') {
|
||||
await _guard()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[privacy] readyGuard fail', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 主动跳过: 不查 storage、不写 storage、不弹窗
|
||||
// 用于 H5 浏览器直接访问特定 URL(子串匹配)时不让隐私弹窗挡住
|
||||
if (options && options.skip) {
|
||||
_needShow = false;
|
||||
_hasChecked = true;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
|
||||
return;
|
||||
}
|
||||
|
||||
// URL/路由规则判断
|
||||
// mode='only' 且未命中 patterns → 跳过
|
||||
// mode='skip' 且命中 patterns → 跳过
|
||||
// 其他情况 → 继续走 storage + 平台检查
|
||||
if (!shouldShowByUrlRule(fallback)) {
|
||||
_needShow = false;
|
||||
_hasChecked = true;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true; // 已决策, 阻止鸿蒙兜底强制弹窗
|
||||
return;
|
||||
}
|
||||
// URL 规则允许弹, 重置 urlRuleDecided 留给鸿蒙兜底分支判断
|
||||
_urlRuleDecided = false;
|
||||
|
||||
const _storageAgreed = getStorageAgreed();
|
||||
|
||||
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
||||
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
||||
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
|
||||
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
||||
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
||||
|
||||
_needShow = result.needAuthorization;
|
||||
_hasChecked = true;
|
||||
|
||||
if (!_needShow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置待弹窗标志
|
||||
// 已 mounted 的页面组件通过事件接收
|
||||
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
|
||||
_pendingShow = true;
|
||||
|
||||
// 通知已 mounted 的页面组件(如果有监听者)
|
||||
try { uni.$emit('privacy:show'); } catch (e) {}
|
||||
|
||||
// 若组件已接收事件并弹窗, 会在 handlePrivacyShow 中调 consumePendingShow 清除 _pendingShow
|
||||
// 若无监听者, _pendingShow 保持 true, 等页面 mounted 时补偿
|
||||
},
|
||||
|
||||
/**
|
||||
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
||||
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
||||
*
|
||||
* 鸿蒙元服务兼容:
|
||||
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
||||
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
||||
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
||||
* @returns {boolean} true=需要弹窗,false=不需要
|
||||
*/
|
||||
consumePendingShow() {
|
||||
if (_pendingShow && _needShow) {
|
||||
_pendingShow = false;
|
||||
return true;
|
||||
}
|
||||
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||||
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
||||
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
||||
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
||||
_pendingShow = false;
|
||||
_needShow = true;
|
||||
_hasChecked = true;
|
||||
return true;
|
||||
}
|
||||
_pendingShow = false;
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户同意:写 storage,标记不再弹
|
||||
* 由 privacy-popup.vue 的 handleAgree 调用
|
||||
*/
|
||||
agree() {
|
||||
try { uni.setStorageSync(STORAGE_KEY, true); } catch (e) {}
|
||||
_needShow = false;
|
||||
_pendingShow = false;
|
||||
try { uni.$emit('privacy:agreed'); } catch (e) {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户拒绝:不写 storage,允许下次再弹
|
||||
* 由 privacy-popup.vue 的 handleDisagree 调用
|
||||
*/
|
||||
disagree() {
|
||||
_needShow = false;
|
||||
_pendingShow = false;
|
||||
try { uni.$emit('privacy:disagreed'); } catch (e) {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前是否需要弹窗(供页面查询)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
needShow() {
|
||||
return _needShow;
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否已执行过 check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasChecked() {
|
||||
return _hasChecked;
|
||||
},
|
||||
|
||||
/**
|
||||
* 配置 URL/路由级别隐私弹窗规则
|
||||
* @param {Object} rules
|
||||
* @param {'all'|'only'|'skip'} [rules.mode='all']
|
||||
* 'all' - 始终走弹窗检查(默认)
|
||||
* 'only' - 白名单: 只有命中 patterns 才弹
|
||||
* 'skip' - 黑名单: 命中 patterns 跳过
|
||||
* @param {string[]} [rules.patterns=[]]
|
||||
* 子串匹配, H5: pathname+search+hash, 小程序/鸿蒙: route+query
|
||||
*/
|
||||
setUrlRules(rules) {
|
||||
setUrlRules(rules)
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前 URL/路由规则下是否要走弹窗检查
|
||||
* @param {Object} [fallback] 兜底路径(同 show 的 urlFallback)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldShowByUrlRule(fallback) {
|
||||
return shouldShowByUrlRule(fallback)
|
||||
},
|
||||
|
||||
/**
|
||||
* 应用本地 base + 远程 patch 的融合结果
|
||||
* - patch=null/缺 → 仅应用 base (并持久化)
|
||||
* - patch 存在 → 走 mergeRules 融合
|
||||
* 应用后 emit 'privacy:rules-updated'
|
||||
* @param {Object} base 本地基础规则 { mode, patterns }
|
||||
* @param {Object} [patch] 远程下发 { mode?, patterns?, merge? }
|
||||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||||
*/
|
||||
applyWithMerge(base, patch) {
|
||||
return applyWithMerge(base, patch)
|
||||
},
|
||||
|
||||
/**
|
||||
* 仅合并 (不写入), 方便上层预览或 dry-run
|
||||
* @param {Object} base
|
||||
* @param {Object} patch
|
||||
*/
|
||||
mergeRules(base, patch) {
|
||||
return mergeRules(base, patch)
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取上次持久化的最终规则 (跨冷启动恢复)
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
loadPersistedRules() {
|
||||
return loadPersistedRules()
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置持久化 key (用于 uniacid 隔离), 由 App.vue 注入
|
||||
* @param {string} key
|
||||
*/
|
||||
setStorageKey(key) {
|
||||
if (typeof key === 'string' && key) _storageKey = key
|
||||
},
|
||||
|
||||
/**
|
||||
* 登记本地 base 规则 (由 App.vue 在启动时调用)
|
||||
* 供后续远程下发时 mergeRules 读取
|
||||
* @param {Object} rules
|
||||
*/
|
||||
setBaseRules(rules) {
|
||||
if (!rules || typeof rules !== 'object') return
|
||||
_baseRules = {
|
||||
mode: rules.mode || 'all',
|
||||
patterns: Array.isArray(rules.patterns) ? rules.patterns.slice() : [],
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取本地 base 规则 (供 store/index.js init 远程 patch 融合)
|
||||
* @returns {{ mode: string, patterns: string[] }}
|
||||
*/
|
||||
__getBaseRules() {
|
||||
return { mode: _baseRules.mode, patterns: _baseRules.patterns.slice() }
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入外部 readiness 守卫 (由 config-external.js ConfigExternal 调用)
|
||||
* show() 时若无 options.readyGuard, 内部使用此 promise
|
||||
* @param {Promise|Function|null} guard
|
||||
*/
|
||||
setExternalReadyGuard(guard) {
|
||||
if (guard === null) { _externalReadyGuard = null; return }
|
||||
if (guard && (typeof guard.then === 'function' || typeof guard === 'function')) {
|
||||
_externalReadyGuard = guard
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入外部 bootTimeout (由 ConfigExternal.getPrivacyBootTimeout 调用)
|
||||
* @param {number} ms
|
||||
*/
|
||||
setExternalBootTimeout(ms) {
|
||||
if (typeof ms === 'number' && ms > 0) _externalBootTimeout = ms
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入 waitChannels 配置 (隐私弹窗启动期必等通道开关)
|
||||
* @param {Object} channels
|
||||
*/
|
||||
setWaitChannels(channels) {
|
||||
if (channels && typeof channels === 'object') {
|
||||
_externalWaitChannels = Object.assign({}, _externalWaitChannels, channels)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||||
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||||
* 前端: 用 btoa 模拟 (弱校验, 仅防简单篡改)
|
||||
* secret='' → 跳过校验 (向后兼容)
|
||||
* @param {string} secret
|
||||
*/
|
||||
setSignSecret(secret) {
|
||||
_signSecret = (typeof secret === 'string') ? secret : ''
|
||||
},
|
||||
|
||||
/**
|
||||
* 校验远程 patch (业务侧可在 store/init 之外单独调用)
|
||||
* - signSecret 未配置 → true (向后兼容)
|
||||
* - patch.sign 缺失 → true (后端未启用签名)
|
||||
* - patch.sign 不匹配 → false
|
||||
* @param {Object} patch
|
||||
* @returns {boolean}
|
||||
*/
|
||||
verifyRemotePatch(patch) {
|
||||
return verifyRemotePatch(patch)
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算给定 patch 的签名 (供调试/单元测试)
|
||||
* @param {Object} patch
|
||||
* @param {string} [secret] - 不传则用 _signSecret
|
||||
* @returns {string}
|
||||
*/
|
||||
computeSign(patch, secret) {
|
||||
return computeSign(patch, secret || _signSecret)
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取当前生效配置 (供调试/单元测试)
|
||||
*/
|
||||
getReadyConfig() {
|
||||
return {
|
||||
readyGuard: _externalReadyGuard,
|
||||
bootTimeout: _externalBootTimeout,
|
||||
waitChannels: Object.assign({}, _externalWaitChannels),
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default PrivacyManager;
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 快应用宿主环境下的下拉刷新 → 原生 webview.reload() 转发 mixin
|
||||
*
|
||||
* 在已启用 enablePullDownRefresh 的 uniapp 页面 mixin 链中加入本文件,
|
||||
* 当用户在原生快应用 webview 内下拉刷新时,会通过 this.$util.requestQuickAppReload()
|
||||
* 把指令发回快应用 shell(对应 hello.ux onMessage 中的 'reload' 处理);
|
||||
* 其余平台走默认的 uni.stopPullDownRefresh 即可。
|
||||
*
|
||||
* 用法:
|
||||
* import pullReload from '@/common/js/mixins/quickapp-pull-refresh.js';
|
||||
* export default { mixins: [..., pullReload] }
|
||||
*
|
||||
* 注意:
|
||||
* - 请确保对应的 pages.json style 块中开启 "enablePullDownRefresh": true
|
||||
* - 不要在本文件里覆盖 onPullDownRefresh,否则会被本页/上游 mixin 拦截失效
|
||||
*/
|
||||
export default {
|
||||
onPullDownRefresh() {
|
||||
try {
|
||||
if (this.$util && typeof this.$util.requestQuickAppReload === 'function'
|
||||
&& this.$util.requestQuickAppReload()) {
|
||||
// 原生侧 webview.reload() 会把整个 H5 页面替换掉,这里只是兜底停动画
|
||||
setTimeout(() => {
|
||||
try { uni.stopPullDownRefresh(); } catch (_) { /* swallow */ }
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[quickapp-pull-refresh] crashed:', e);
|
||||
}
|
||||
try { uni.stopPullDownRefresh(); } catch (_) { /* swallow */ }
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// 文件分享工具函数
|
||||
import Lang from './lang.js';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 生成文件预览链接
|
||||
* @param {Object} file - 文件对象,包含 name 和 url 属性
|
||||
* @returns {string} - 生成的文件预览链接
|
||||
*/
|
||||
generateFilePreviewUrl(file) {
|
||||
// 推断文件类型
|
||||
let fileType = '';
|
||||
if (file.name) {
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
fileType = 'video';
|
||||
}
|
||||
}
|
||||
|
||||
// 构建文件预览链接
|
||||
const previewUrl = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(file.name)}&fileUrl=${encodeURIComponent(file.url || '')}&fileType=${encodeURIComponent(fileType)}`;
|
||||
return previewUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* 分享文件
|
||||
* @param {Object} file - 文件对象,包含 name 和 url 属性
|
||||
* @param {string} title - 分享标题,默认使用文件名
|
||||
* @param {string} desc - 分享描述,默认使用固定文案
|
||||
*/
|
||||
shareFile(file, title = file.name, desc = Lang.lang('share.defaultDesc', { name: file.name })) {
|
||||
// 生成文件预览链接
|
||||
const filePreviewUrl = this.generateFilePreviewUrl(file);
|
||||
|
||||
// #ifdef H5
|
||||
// H5 平台分享
|
||||
if (navigator.share) {
|
||||
// 使用 Web Share API
|
||||
navigator.share({
|
||||
title: title,
|
||||
text: desc,
|
||||
url: filePreviewUrl
|
||||
}).catch(err => {
|
||||
console.error('分享失败:', err);
|
||||
uni.showToast({ title: Lang.lang('share.shareFailed'), icon: 'none' });
|
||||
});
|
||||
} else {
|
||||
// 不支持 Web Share API 的浏览器
|
||||
uni.setClipboardData({
|
||||
data: filePreviewUrl,
|
||||
success: () => {
|
||||
uni.showToast({ title: Lang.lang('share.linkCopied'), icon: 'success' });
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: Lang.lang('share.copyFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序分享
|
||||
uni.showActionSheet({
|
||||
itemList: [Lang.lang('share.sendToFriend'), Lang.lang('share.shareToTimeline')],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
// 发送给朋友
|
||||
uni.shareAppMessage({
|
||||
title: title,
|
||||
path: filePreviewUrl,
|
||||
success: () => {
|
||||
uni.showToast({ title: Lang.lang('share.shareSuccess'), icon: 'success' });
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: Lang.lang('share.shareFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
} else if (res.tapIndex === 1) {
|
||||
// 分享到朋友圈
|
||||
uni.shareTimeline({
|
||||
title: desc,
|
||||
path: filePreviewUrl,
|
||||
success: () => {
|
||||
uni.showToast({ title: Lang.lang('share.shareSuccess'), icon: 'success' });
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: Lang.lang('share.shareFailed'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 用户取消分享
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
};
|
||||
@@ -1,185 +1,215 @@
|
||||
import config from './config.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
timeoutObj: null, //ping定时器
|
||||
servicer_id: null, //绑定
|
||||
pingInterval: config.pingInterval //本地端主动给服务器ping的时间, 0 则不开启
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
let that = this;
|
||||
// 因为图片上传,所以不能onhide关闭长链接,但是每次打开客服都会有重复请求,所以优先关闭再去打开长链接
|
||||
uni.closeSocket();
|
||||
// .判断是否已连接
|
||||
that.checkOpenSocket();
|
||||
// uni.onSocketClose(function(res) {
|
||||
// console.log('WebSocket 已关闭!');
|
||||
// });
|
||||
},
|
||||
methods: {
|
||||
// 判断是否已连接
|
||||
checkOpenSocket() {
|
||||
let self = this;
|
||||
uni.sendSocketMessage({
|
||||
data: 'ping',
|
||||
success: (res) => {
|
||||
},
|
||||
fail: (err) => { // 未连接打开websocket连接
|
||||
self.openConnection();
|
||||
}
|
||||
});
|
||||
},
|
||||
openConnection() { // 打开连接
|
||||
// uni.closeSocket(); // 确保已经关闭后再重新打开
|
||||
uni.connectSocket({
|
||||
url: config.webSocket,
|
||||
method: 'POST',
|
||||
success(res) {
|
||||
},
|
||||
fail(err) {
|
||||
}
|
||||
});
|
||||
this.onSocketMessage(); // 打开成功监听服务器返回的消息
|
||||
},
|
||||
// 打开成功监听服务器返回的消息
|
||||
onSocketMessage() { // 消息
|
||||
let that = this;
|
||||
this.pingInterval = config.pingInterval;
|
||||
this.timeoutObj = null;
|
||||
uni.onSocketMessage((res) => { //type:init,connect,close,string,order,goods
|
||||
let msg = JSON.parse(res.data);
|
||||
if (msg.type == 'close') {
|
||||
clearInterval(that.timeoutObj);
|
||||
that.timeoutObj = null;
|
||||
uni.closeSocket();
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.getSocketMsg(res.data); // 监听到有新服务器消息
|
||||
});
|
||||
},
|
||||
// 监听到有新服务器消息
|
||||
getSocketMsg(reData) { // 监听到服务器消息
|
||||
let that = this;
|
||||
// console.log(reData)
|
||||
let giveMsg = JSON.parse(reData);
|
||||
let data = {
|
||||
isItMe: false,
|
||||
};
|
||||
data.contentType = giveMsg.type;
|
||||
// alert(data.contentType)
|
||||
if (giveMsg.type == 'init') {
|
||||
// alert(123)
|
||||
that.$api.sendRequest({
|
||||
url: '/servicer/api/chat/bind',
|
||||
data: {
|
||||
client_id: giveMsg.data.client_id,
|
||||
site_id: that.siteId
|
||||
},
|
||||
success(res) {
|
||||
if (res.code == 0) {
|
||||
that.servicer_id = res.data.servicer_id;
|
||||
} else {
|
||||
that.servicer_id = 0;
|
||||
}
|
||||
that.getChatList();
|
||||
}
|
||||
})
|
||||
} else if (giveMsg.type == 'connect') {
|
||||
// that.servicer_id = giveMsg.data.servicer_id;
|
||||
// let NewArr = that.messageList;
|
||||
// let index = null;
|
||||
// for (let i = 0; i < NewArr.length; i++) {
|
||||
// if (NewArr[i].contentType == 'online' || NewArr[i].contentType == 'noline') {
|
||||
// index = i;
|
||||
// }
|
||||
// }
|
||||
// NewArr.splice(index, 1)
|
||||
// that.messageList = NewArr;
|
||||
// let obj = {}
|
||||
// if (that.servicer_id > 0) {
|
||||
// obj.contentType = 'online';
|
||||
// } else if (that.servicer_id == 0) {
|
||||
// obj.contentType = 'noline';
|
||||
// }
|
||||
// that.messageList.push(obj);
|
||||
return false;
|
||||
} else if (giveMsg.type == 'string') {
|
||||
data.content = giveMsg.data.servicer_say;
|
||||
} else if (giveMsg.type == 'image') {
|
||||
data.image = giveMsg.data.servicer_say;
|
||||
} else if (giveMsg.type == 'order') {
|
||||
data.order_id = giveMsg.data.order_id;
|
||||
} else if (giveMsg.type == 'goodssku') {
|
||||
data.sku_id = giveMsg.data.goods_sku_id;
|
||||
}
|
||||
if (giveMsg.type == 'init') return;
|
||||
that.messageList.push(data);
|
||||
that.$nextTick(() => {
|
||||
that.setPageScrollTo()
|
||||
})
|
||||
},
|
||||
// 检测心跳reset
|
||||
reset() {
|
||||
clearInterval(this.timeoutObj);
|
||||
this.start(); // 启动心跳
|
||||
},
|
||||
// 启动心跳 start
|
||||
start() {
|
||||
let self = this;
|
||||
this.timeoutObj = setInterval(function () {
|
||||
uni.sendSocketMessage({
|
||||
data: 'ping',
|
||||
success: (res) => {
|
||||
},
|
||||
fail: (err) => {
|
||||
self.openConnection();
|
||||
}
|
||||
});
|
||||
}, this.pingInterval);
|
||||
}
|
||||
},
|
||||
// onHide() {
|
||||
// // alert("关闭")
|
||||
// // 改之前的
|
||||
// // console.log("我出发了")
|
||||
// // this.checkOpenSocket();
|
||||
// clearInterval(this.timeoutObj);
|
||||
// this.timeoutObj = null;
|
||||
// this.$api.sendRequest({
|
||||
// url: '/servicer/api/chat/bye',
|
||||
// data: {
|
||||
// servicer_id: this.servicer_id,
|
||||
// site_id: this.siteId
|
||||
// },
|
||||
// success(res) {
|
||||
// uni.closeSocket();
|
||||
// },
|
||||
// fail: (err) => {
|
||||
// uni.closeSocket();
|
||||
// }
|
||||
// });
|
||||
// },
|
||||
onUnload() {
|
||||
// alert("关闭")
|
||||
clearInterval(this.timeoutObj);
|
||||
this.timeoutObj = null;
|
||||
this.$api.sendRequest({
|
||||
url: '/servicer/api/chat/bye',
|
||||
data: {
|
||||
servicer_id: this.servicer_id,
|
||||
site_id: this.siteId
|
||||
},
|
||||
success(res) {
|
||||
// alert("关闭1")
|
||||
uni.closeSocket();
|
||||
},
|
||||
fail: (err) => {
|
||||
// alert("关闭2")
|
||||
uni.closeSocket();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
import config from './config.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
timeoutObj: null, //ping定时器
|
||||
servicer_id: null, //绑定
|
||||
pingInterval: config.pingInterval //本地端主动给服务器ping的时间, 0 则不开启
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
let that = this;
|
||||
// 因为图片上传,所以不能onhide关闭长链接,但是每次打开客服都会有重复请求,所以优先关闭再去打开长链接
|
||||
uni.closeSocket();
|
||||
// .判断是否已连接
|
||||
that.checkOpenSocket();
|
||||
// uni.onSocketClose(function(res) {
|
||||
// console.log('WebSocket 已关闭!');
|
||||
// });
|
||||
},
|
||||
methods: {
|
||||
// 判断是否已连接
|
||||
checkOpenSocket() {
|
||||
console.log('判断是否已连接');
|
||||
// alert('判断是否已连接')
|
||||
let self = this;
|
||||
uni.sendSocketMessage({
|
||||
data: 'ping',
|
||||
success: (res) => {
|
||||
console.log('连接成功,检查');
|
||||
// alert('连接成功,检查')
|
||||
// self.getChatList();
|
||||
},
|
||||
fail: (err) => { // 未连接打开websocket连接
|
||||
console.log('连接失败');
|
||||
// alert('连接失败')
|
||||
self.openConnection();
|
||||
}
|
||||
});
|
||||
},
|
||||
openConnection() { // 打开连接
|
||||
console.log('打开连接');
|
||||
// alert('打开连接')
|
||||
// uni.closeSocket(); // 确保已经关闭后再重新打开
|
||||
|
||||
uni.connectSocket({
|
||||
url: config.webSocket,
|
||||
method: 'POST',
|
||||
success(res) {
|
||||
console.log('连接成功 connectSocket=', res);
|
||||
// alert('连接成功 connectSocket=', res);
|
||||
},
|
||||
fail(err) {
|
||||
console.log('连接失败 connectSocket=', err);
|
||||
}
|
||||
});
|
||||
// 监听 WebSocket 连接错误事件
|
||||
uni.onSocketError((res)=>{
|
||||
console.error('WebSocket 连接失败:', res.errMsg);
|
||||
this.chatListInit();
|
||||
this.getChatList();
|
||||
});
|
||||
// uni.onSocketOpen((res) => {
|
||||
// console.log('连接成功', res);
|
||||
// });
|
||||
this.onSocketMessage(); // 打开成功监听服务器返回的消息
|
||||
},
|
||||
// 打开成功监听服务器返回的消息
|
||||
onSocketMessage() { // 消息
|
||||
console.log("开始监听");
|
||||
|
||||
let that = this;
|
||||
this.pingInterval = config.pingInterval;
|
||||
if(this.timeoutObj) clearInterval(this.timeoutObj);
|
||||
this.timeoutObj = null;
|
||||
uni.onSocketMessage((res) => { //type:init,connect,close,string,order,goods
|
||||
let msg = JSON.parse(res.data);
|
||||
console.log("监听该服务器消息", res);
|
||||
if (msg.type == 'close') {
|
||||
clearInterval(that.timeoutObj);
|
||||
that.timeoutObj = null;
|
||||
uni.closeSocket();
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.getSocketMsg(res.data); // 监听到有新服务器消息
|
||||
});
|
||||
},
|
||||
// 监听到有新服务器消息
|
||||
getSocketMsg(reData) { // 监听到服务器消息
|
||||
let that = this;
|
||||
let giveMsg = JSON.parse(reData);
|
||||
let data = {
|
||||
isItMe: false,
|
||||
};
|
||||
data.contentType = giveMsg.type;
|
||||
// alert(data.contentType)
|
||||
if (giveMsg.type == 'init') {
|
||||
// alert(123)
|
||||
that.$api.sendRequest({
|
||||
url: '/servicer/api/chat/bind',
|
||||
data: {
|
||||
client_id: giveMsg.data.client_id,
|
||||
site_id: that.siteId
|
||||
},
|
||||
success(res) {
|
||||
if (res.code == 0) {
|
||||
that.servicer_id = res.data.servicer_id;
|
||||
} else {
|
||||
that.servicer_id = 0;
|
||||
}
|
||||
that.chatListInit();
|
||||
that.getChatList();
|
||||
}
|
||||
})
|
||||
} else if (giveMsg.type == 'connect') {
|
||||
// that.servicer_id = giveMsg.data.servicer_id;
|
||||
// let NewArr = that.messageList;
|
||||
// let index = null;
|
||||
// for (let i = 0; i < NewArr.length; i++) {
|
||||
// if (NewArr[i].contentType == 'online' || NewArr[i].contentType == 'noline') {
|
||||
// index = i;
|
||||
// }
|
||||
// }
|
||||
// NewArr.splice(index, 1)
|
||||
// that.messageList = NewArr;
|
||||
// let obj = {}
|
||||
// if (that.servicer_id > 0) {
|
||||
// obj.contentType = 'online';
|
||||
// } else if (that.servicer_id == 0) {
|
||||
// obj.contentType = 'noline';
|
||||
// }
|
||||
// that.messageList.push(obj);
|
||||
return false;
|
||||
} else if (giveMsg.type == 'string') {
|
||||
data.content = giveMsg.data.servicer_say;
|
||||
} else if (giveMsg.type == 'image') {
|
||||
data.image = giveMsg.data.servicer_say;
|
||||
} else if (giveMsg.type == 'order') {
|
||||
data.order_id = giveMsg.data.order_id;
|
||||
} else if (giveMsg.type == 'goodssku') {
|
||||
data.sku_id = giveMsg.data.goods_sku_id;
|
||||
}
|
||||
if (giveMsg.type == 'init') return;
|
||||
that.messageList.push(data);
|
||||
that.$nextTick(() => {
|
||||
that.setPageScrollTo()
|
||||
})
|
||||
},
|
||||
// 检测心跳reset
|
||||
reset() {
|
||||
console.log("检测心跳");
|
||||
clearInterval(this.timeoutObj);
|
||||
this.start(); // 启动心跳
|
||||
},
|
||||
// 启动心跳 start
|
||||
start() {
|
||||
console.log("启动心跳");
|
||||
let self = this;
|
||||
this.timeoutObj = setInterval(function () {
|
||||
uni.sendSocketMessage({
|
||||
data: 'ping',
|
||||
success: (res) => {
|
||||
console.log('连接中....');
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('连接失败重新连接....');
|
||||
self.openConnection();
|
||||
}
|
||||
});
|
||||
}, this.pingInterval);
|
||||
}
|
||||
},
|
||||
// onHide() {
|
||||
// // alert("关闭")
|
||||
// // 改之前的
|
||||
// // console.log("我出发了")
|
||||
// // this.checkOpenSocket();
|
||||
// clearInterval(this.timeoutObj);
|
||||
// this.timeoutObj = null;
|
||||
// this.$api.sendRequest({
|
||||
// url: '/servicer/api/chat/bye',
|
||||
// data: {
|
||||
// servicer_id: this.servicer_id,
|
||||
// site_id: this.siteId
|
||||
// },
|
||||
// success(res) {
|
||||
// uni.closeSocket();
|
||||
// },
|
||||
// fail: (err) => {
|
||||
// uni.closeSocket();
|
||||
// }
|
||||
// });
|
||||
// },
|
||||
onUnload() {
|
||||
// alert("关闭")
|
||||
clearInterval(this.timeoutObj);
|
||||
this.timeoutObj = null;
|
||||
this.$api.sendRequest({
|
||||
url: '/servicer/api/chat/bye',
|
||||
data: {
|
||||
servicer_id: this.servicer_id,
|
||||
site_id: this.siteId
|
||||
},
|
||||
success(res) {
|
||||
// alert("关闭1")
|
||||
uni.closeSocket();
|
||||
},
|
||||
fail: (err) => {
|
||||
// alert("关闭2")
|
||||
uni.closeSocket();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
// ============================================================
|
||||
// 启动屏单例状态
|
||||
//
|
||||
// 设计要点:
|
||||
// 1. 状态 + 事件双通道: 组件既可 watch visible,也可监听 'splash:hide' 事件
|
||||
// 解决 uni.$emit 在组件挂载前发出就丢失的问题 (memory lesson)
|
||||
// 2. 软隐藏 (softHide): 标记应该 hide, 等组件 mount 后再真正 hide
|
||||
// 解决 onLaunch 跑完 + hide() 早于 page mount 的时序问题
|
||||
// 3. 防重入: show()/hide() 多次调用安全,内部用 guard
|
||||
// 4. 兜底超时: 默认 8s 未关闭则强制 hide,防止卡死
|
||||
// 5. 平台隔离: APP-PLUS 有原生 splash,这里直接返回 false
|
||||
// 6. 跨平台兼容: 同步状态可被 Vue 组件直接 watch
|
||||
// ============================================================
|
||||
|
||||
// APP-PLUS 直接返回 false (有原生启动屏)
|
||||
function needSplash() {
|
||||
// #ifdef APP-PLUS
|
||||
return false;
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
return true;
|
||||
// #endif
|
||||
}
|
||||
|
||||
// H5 平台特殊处理: Lang.boot() 在 main.js 中 await, 此时 Vue 还没挂载,
|
||||
// 组件无法渲染, 内部降级为 uni.showLoading 做兜底 (虽然样式较丑但至少有遮罩)
|
||||
function useNativeLoading() {
|
||||
// #ifdef H5
|
||||
return true;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false;
|
||||
// #endif
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
|
||||
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
|
||||
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
|
||||
|
||||
// 启动期阶段定义 (id → { percent, label })
|
||||
// percent: 阶段完成的百分比 (0-100)
|
||||
// label: 阶段显示文案 (i18n key 由 splash 组件消费, 也可直接用最终文案)
|
||||
const STAGES = {
|
||||
boot: { percent: 5, label: 'Loading...' },
|
||||
lang: { percent: 30, label: 'Loading language...' },
|
||||
theme: { percent: 50, label: 'Applying theme...' },
|
||||
override: { percent: 65, label: 'Loading theme tokens...' },
|
||||
config: { percent: 80, label: 'Fetching configuration...' },
|
||||
privacy: { percent: 95, label: 'Preparing privacy dialog...' },
|
||||
done: { percent: 100, label: 'Ready' },
|
||||
};
|
||||
|
||||
class SplashState {
|
||||
constructor() {
|
||||
this._visible = false;
|
||||
this._timeoutId = null;
|
||||
this._listeners = [];
|
||||
this._shownAt = 0;
|
||||
// 软隐藏相关
|
||||
this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
|
||||
this._hasMounted = false; // 是否有 splash 组件 mount 过
|
||||
this._mountGraceId = null; // 软隐藏兜底定时器 (等 mount)
|
||||
// 页面就绪等待
|
||||
this._pageReady = false; // 页面内容是否已就绪 (由首页通知)
|
||||
this._pageReadyGraceId = null; // 页面就绪兜底定时器
|
||||
// 进度上报
|
||||
this._stage = 'boot';
|
||||
this._percent = 0;
|
||||
this._stageSet = new Set(['boot']); // 已报告阶段
|
||||
// 进度节流: 16ms 内的多次 reportProgress 合并成一次 emit
|
||||
// 避免多通道并发时, splash 组件 re-render 风暴
|
||||
this._pendingProgress = null; // 待 emit 的最新进度
|
||||
this._flushTimer = null; // 节流定时器
|
||||
}
|
||||
|
||||
show(opts = {}) {
|
||||
if (!needSplash()) return;
|
||||
if (this._visible) return; // 防重入
|
||||
|
||||
this._visible = true;
|
||||
this._shownAt = Date.now();
|
||||
this._pendingHide = null;
|
||||
this._hasMounted = false;
|
||||
this._pageReady = false;
|
||||
this._stage = 'boot';
|
||||
// 初始化时直接显示 'boot' 阶段的 5%, 而不是 0%
|
||||
// 避免首帧出现"进度条 0%"的视觉突兀
|
||||
this._percent = (STAGES.boot && STAGES.boot.percent) || 5;
|
||||
this._stageSet = new Set(['boot']);
|
||||
this._pendingProgress = null;
|
||||
if (this._flushTimer) {
|
||||
clearTimeout(this._flushTimer);
|
||||
this._flushTimer = null;
|
||||
}
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
}
|
||||
if (this._pageReadyGraceId) {
|
||||
clearTimeout(this._pageReadyGraceId);
|
||||
this._pageReadyGraceId = null;
|
||||
}
|
||||
this._emit('splash:show');
|
||||
|
||||
// H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染)
|
||||
// 注意: mask 设为 false。原 mask:true 会在 H5 注入 .uni-mask 全屏透明蒙层,
|
||||
// 配合 z-index:99999 的 Vue splash 一起把首页触摸全部拦截,导致 H5
|
||||
// 首屏在 boot 期间无法滚动。Vue splash 组件本身已带白色背景提供视觉遮罩,
|
||||
// 这里只要 loading 文字提示,不需要拦截触摸。
|
||||
if (useNativeLoading()) {
|
||||
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
|
||||
}
|
||||
|
||||
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
||||
if (this._timeoutId) {
|
||||
clearTimeout(this._timeoutId);
|
||||
this._timeoutId = null;
|
||||
}
|
||||
this._timeoutId = setTimeout(() => {
|
||||
// 兜底: 超时强制关闭,防止卡死 (走硬隐藏路径, 不等组件)
|
||||
if (this._visible) {
|
||||
console.warn('[splash-state] auto-hide after timeout', timeout);
|
||||
this._doHide({ reason: 'timeout' });
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏 splash
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.reason - 隐藏原因
|
||||
* - 'boot-complete': 软隐藏, 等组件 mount 后再真关 (默认)
|
||||
* - 'timeout': 硬隐藏, 立即关 (兜底用)
|
||||
* - 'manual': 硬隐藏, 立即关
|
||||
* @param {boolean} [opts.soft] - 强制走软隐藏
|
||||
* @param {string} [opts.mode] - 离开动画模式
|
||||
* - 'default': 默认 480ms 动画
|
||||
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
||||
*/
|
||||
hide(opts = {}) {
|
||||
const reason = opts.reason || 'manual';
|
||||
// boot-complete 默认走软隐藏, 避免时序问题
|
||||
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||
const mode = opts.mode === 'fast' ? 'fast' : 'default';
|
||||
|
||||
if (useSoft) {
|
||||
this._softHide({ reason, mode });
|
||||
} else {
|
||||
this._doHide({ reason, mode });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
|
||||
* 如果组件 1s 内没 mount, 兜底自动 hide
|
||||
* 如果页面 2s 内没就绪, 兜底自动 hide
|
||||
*/
|
||||
_softHide(opts) {
|
||||
if (!this._visible) return; // 已经隐藏了
|
||||
this._pendingHide = opts;
|
||||
|
||||
// 如果组件已经 mount 且页面已就绪, 立即隐藏
|
||||
if (this._hasMounted && this._pageReady) {
|
||||
this._flushPendingHide();
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底: 1s 内没 mount, 强制隐藏
|
||||
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = setTimeout(() => {
|
||||
if (this._pendingHide) {
|
||||
this._flushPendingHide();
|
||||
}
|
||||
}, MOUNT_GRACE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正触发软隐藏
|
||||
* 如果页面还未就绪, 等待页面就绪后再 hide
|
||||
*/
|
||||
_flushPendingHide() {
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
}
|
||||
|
||||
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
|
||||
if (!this._pageReady) {
|
||||
// 兜底: 2s 内没就绪, 强制隐藏
|
||||
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
|
||||
this._pageReadyGraceId = setTimeout(() => {
|
||||
if (this._visible && this._pendingHide) {
|
||||
console.warn('[splash-state] auto-hide after page-ready timeout');
|
||||
this._doHide(this._pendingHide || { reason: 'page-ready-timeout' });
|
||||
}
|
||||
}, PAGE_READY_GRACE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const opts = this._pendingHide;
|
||||
this._pendingHide = null;
|
||||
this._doHide(opts || { reason: 'mount-completed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际隐藏 (硬路径)
|
||||
*/
|
||||
_doHide(opts) {
|
||||
if (!this._visible) return; // 防重入
|
||||
if (this._timeoutId) {
|
||||
clearTimeout(this._timeoutId);
|
||||
this._timeoutId = null;
|
||||
}
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
}
|
||||
if (this._pageReadyGraceId) {
|
||||
clearTimeout(this._pageReadyGraceId);
|
||||
this._pageReadyGraceId = null;
|
||||
}
|
||||
this._pendingHide = null;
|
||||
this._visible = false;
|
||||
// H5 降级对应的关闭
|
||||
if (useNativeLoading()) {
|
||||
try { uni.hideLoading(); } catch (e) {}
|
||||
}
|
||||
this._emit('splash:hide', {
|
||||
reason: opts.reason || 'manual',
|
||||
mode: opts.mode || 'default',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件 mount 后通知 state 模块
|
||||
* - 标记 _hasMounted
|
||||
* - 如果有 _pendingHide, 触发真关
|
||||
* - H5 降级场景: 不再立即关掉原生 loading, 等待页面就绪后再关
|
||||
*/
|
||||
notifyMounted() {
|
||||
if (this._hasMounted) return;
|
||||
this._hasMounted = true;
|
||||
if (this._pendingHide) {
|
||||
setTimeout(() => this._flushPendingHide(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面内容就绪后通知 state 模块
|
||||
* - 标记 _pageReady
|
||||
* - 如果有 _pendingHide, 触发真关
|
||||
* 调用时机: 首页 diy-group 渲染完成后调用
|
||||
*/
|
||||
notifyPageReady() {
|
||||
if (this._pageReady) return;
|
||||
this._pageReady = true;
|
||||
if (this._pageReadyGraceId) {
|
||||
clearTimeout(this._pageReadyGraceId);
|
||||
this._pageReadyGraceId = null;
|
||||
}
|
||||
if (this._pendingHide) {
|
||||
// 给页面一帧时间完成渲染, 再隐藏
|
||||
setTimeout(() => this._flushPendingHide(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
|
||||
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
|
||||
*/
|
||||
notifyUnmounted() {
|
||||
// 不重置 _hasMounted, 因为软隐藏的 _pendingHide 处理不依赖 unmount
|
||||
}
|
||||
|
||||
isShow() {
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
hasPendingHide() {
|
||||
return this._pendingHide != null;
|
||||
}
|
||||
|
||||
elapsed() {
|
||||
if (!this._visible) return 0;
|
||||
return Date.now() - this._shownAt;
|
||||
}
|
||||
|
||||
on(event, fn) {
|
||||
if (!this._listeners[event]) this._listeners[event] = [];
|
||||
this._listeners[event].push(fn);
|
||||
}
|
||||
|
||||
off(event, fn) {
|
||||
if (!this._listeners[event]) return;
|
||||
this._listeners[event] = this._listeners[event].filter(f => f !== fn);
|
||||
}
|
||||
|
||||
_emit(event, payload) {
|
||||
const arr = this._listeners[event];
|
||||
if (!arr || !arr.length) return;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报启动期进度
|
||||
* 节流: 16ms 内的多次 reportProgress 合并成 1 次 emit, 避免 splash re-render 风暴
|
||||
* 取较大值, 不允许后退 (即使 throttle 合并也不会让进度条后退)
|
||||
* @param {string} stageId - 阶段 id (STAGES 中定义)
|
||||
* 'boot' | 'lang' | 'theme' | 'override' | 'config' | 'privacy' | 'done'
|
||||
* 或自定义阶段 id (传 percent + label)
|
||||
* @param {Object} [meta] - { percent?, label?, source?: 'channel'|'timeout'|'error' }
|
||||
* 阶段完成时 percent 应该是该阶段的"完成态"百分比
|
||||
*/
|
||||
reportProgress(stageId, meta) {
|
||||
const def = STAGES[stageId]
|
||||
let percent, label
|
||||
if (def) {
|
||||
percent = (meta && typeof meta.percent === 'number') ? meta.percent : def.percent
|
||||
label = (meta && meta.label) || def.label
|
||||
} else {
|
||||
// 自定义阶段
|
||||
percent = (meta && typeof meta.percent === 'number') ? meta.percent : this._percent
|
||||
label = (meta && meta.label) || stageId
|
||||
}
|
||||
// 取较大值, 不允许后退
|
||||
if (percent < this._percent) percent = this._percent
|
||||
this._percent = percent
|
||||
this._stage = stageId
|
||||
if (!this._stageSet.has(stageId)) this._stageSet.add(stageId)
|
||||
|
||||
// 节流 emit: 16ms 内的多次调用合并为 1 次
|
||||
// 内部状态立即更新 (不延迟), 但 emit 推迟
|
||||
// 注意: percent 不允许后退的逻辑已在上方处理, 所以 throttle 合并不会丢进度
|
||||
const payload = {
|
||||
stage: stageId,
|
||||
label,
|
||||
percent,
|
||||
source: (meta && meta.source) || 'channel',
|
||||
at: Date.now(),
|
||||
}
|
||||
this._pendingProgress = payload
|
||||
if (this._flushTimer) return // 已有 pending, 等 timer flush
|
||||
this._flushTimer = setTimeout(() => {
|
||||
this._flushTimer = null
|
||||
if (this._pendingProgress) {
|
||||
const p = this._pendingProgress
|
||||
this._pendingProgress = null
|
||||
this._emit('splash:progress', p)
|
||||
}
|
||||
}, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听进度上报
|
||||
* callback({ stage, label, percent, source, at })
|
||||
*
|
||||
* 注意: 与 on() 不同, onProgress 是单例 listener (多个组件时只最后一个生效)
|
||||
* 设计意图: splash 进度条 UI 在系统中只有一个渲染者, 不需要多播
|
||||
*/
|
||||
onProgress(callback) {
|
||||
this._listeners['splash:progress'] = [callback]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑进度上报 (清空 listener)
|
||||
*/
|
||||
offProgress() {
|
||||
this._listeners['splash:progress'] = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前进度 (供组件初始化)
|
||||
* 同时返回已解析的 label, 避免组件再维护一份 STAGE_LABEL
|
||||
*/
|
||||
getProgress() {
|
||||
const def = STAGES[this._stage]
|
||||
return {
|
||||
stage: this._stage,
|
||||
percent: this._percent,
|
||||
label: def ? def.label : this._stage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露 STAGES 表 (供 splash 组件直接消费, 避免重复维护一份)
|
||||
*/
|
||||
getStageDef(stageId) {
|
||||
return STAGES[stageId]
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即刷新挂起的进度更新
|
||||
* - reportProgress 默认 16ms throttle (1 帧), 避免连续触发 splash 组件多次 re-render
|
||||
* - 业务侧若要"立即"刷新 (例如 splash 即将隐藏), 可调本方法
|
||||
* - 不存在 pending 时 noop
|
||||
*/
|
||||
flushProgress() {
|
||||
if (this._pendingProgress) {
|
||||
const p = this._pendingProgress
|
||||
this._pendingProgress = null
|
||||
if (this._flushTimer) {
|
||||
clearTimeout(this._flushTimer)
|
||||
this._flushTimer = null
|
||||
}
|
||||
this._emit('splash:progress', p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const splashState = new SplashState();
|
||||
|
||||
export default splashState;
|
||||
export { splashState };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +0,0 @@
|
||||
/**
|
||||
* 主题 token 远端覆写运行时
|
||||
*
|
||||
* 设计原则(与 lang.js 完全同构):
|
||||
* 1) 后端只存"对要个性化的 token"做覆写 KV,前端拿本地 style_color.js + 远端覆写 合并
|
||||
* 2) 三态机: unknown / ok / fail
|
||||
* 3) 多租户 × 多端平台 × 多模式(brand/light/dark)三维隔离缓存
|
||||
* 4) 失败归一: timeout / network / http / parse / exception
|
||||
* 5) 失败时使用本地 style_color.js 兜底
|
||||
* 6) 不修改 Vuex.themeStyle / themeColorSet / 页面 wrapper,只把"合并后的 token map"
|
||||
* 提供给 store/index.js 的 applyThemeWithRemote action 使用
|
||||
*
|
||||
* 暴露的 API:
|
||||
* ThemeOverride.boot() 首屏阻塞拉取 + 缓存预热(失败静默)
|
||||
* ThemeOverride.refresh(force) 重新拉取 + 重新应用
|
||||
* ThemeOverride.getByTheme(theme) 取单个模式桶(brand/light/dark)的合并 KV
|
||||
* ThemeOverride.getAll() { brand, light, dark }
|
||||
* ThemeOverride.onUniacidChange() 切租户后清旧缓存
|
||||
* ThemeOverride.getRemoteStatus() 'unknown' | 'ok' | 'fail'
|
||||
* ThemeOverride.onRemoteStatusChange(fn) 订阅状态变化
|
||||
* ThemeOverride.__debug_cache 内部缓存(测试用)
|
||||
*
|
||||
* 关键: 一次 boot 只发一次 HTTP 请求,后端 getAllByTenant 一次出 brand/light/dark 三桶
|
||||
* 避免对每个 theme 单独请求造成的 3 倍带宽浪费
|
||||
*/
|
||||
|
||||
import { themeConfig } from './config-external.js';
|
||||
import Config from './config.js';
|
||||
import http from './http.js';
|
||||
import { Platform } from './platform.js';
|
||||
|
||||
const THEMES = ['brand', 'light', 'dark'];
|
||||
|
||||
// 远端覆写:内存缓存
|
||||
// shape: { [theme]: { version, map, ts, global_version } }
|
||||
const overrideCache = {};
|
||||
|
||||
// 远端全量 preset 内存缓存(remote-only 模式用)
|
||||
// shape: { [presetName]: { ...tokenMap } }
|
||||
const presetsCache = {};
|
||||
|
||||
// 远端覆写:并发去重(以"全量拉取"为粒度)
|
||||
let allInflight = null;
|
||||
|
||||
// 远端全量 preset 拉取:并发去重
|
||||
let presetsInflight = null;
|
||||
|
||||
// 远端可用性三态机
|
||||
let remoteStatus = 'unknown';
|
||||
let remoteStatusAt = 0;
|
||||
const remoteStatusListeners = [];
|
||||
|
||||
// 配置项缓存
|
||||
const REMOTE = (themeConfig && themeConfig.overrideRemote) || {};
|
||||
|
||||
/**
|
||||
* 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认)
|
||||
*/
|
||||
function normalizeSource(s) {
|
||||
return ['local', 'remote', 'hybrid'].includes(s) ? s : 'remote';
|
||||
}
|
||||
const SOURCE = normalizeSource(REMOTE.source || 'remote');
|
||||
|
||||
/**
|
||||
* 远端失败原因归一
|
||||
*/
|
||||
function normalizeReason(s) {
|
||||
const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown'];
|
||||
return allow.includes(s) ? s : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 远端成功/失败状态机
|
||||
*/
|
||||
function setRemoteStatus(s, detail) {
|
||||
if (remoteStatus === s) return;
|
||||
const prev = remoteStatus;
|
||||
remoteStatus = s;
|
||||
remoteStatusAt = Date.now();
|
||||
const safeDetail = {
|
||||
reason: normalizeReason(detail && detail.reason),
|
||||
theme: detail && detail.theme,
|
||||
};
|
||||
remoteStatusListeners.forEach(fn => {
|
||||
try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ }
|
||||
});
|
||||
try { uni.$emit && uni.$emit('theme:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {}
|
||||
if (s === 'fail') {
|
||||
try { uni.$emit && uni.$emit('theme:fallback', { reason: safeDetail.reason }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前租户 id
|
||||
*/
|
||||
function getCurrentUniacid() {
|
||||
const u = uni.getStorageSync('uniacid');
|
||||
return u ? parseInt(u, 10) || Config.uniacid : Config.uniacid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前平台类型
|
||||
*/
|
||||
function getCurrentAppType() {
|
||||
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化 key: <prefix><uniacid>_<appType>_<theme>
|
||||
*/
|
||||
function getOverrideStorageKey(theme) {
|
||||
const u = getCurrentUniacid();
|
||||
const a = getCurrentAppType();
|
||||
return `${REMOTE.storagePrefix || 'THEME_OVR_'}${u}_${a}_${theme}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存 cache key: <uniacid>_<appType>_<theme>
|
||||
*/
|
||||
function getOverrideMemKey(theme) {
|
||||
return `${getCurrentUniacid()}_${getCurrentAppType()}_${theme}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 storage 预热单个 theme 桶到内存(首屏兜底)
|
||||
* 仅在内存还没有该 key 时才读 storage,避免覆盖更新值
|
||||
*/
|
||||
function preheatFromStorage(theme) {
|
||||
const mk = getOverrideMemKey(theme);
|
||||
if (overrideCache[mk]) return;
|
||||
try {
|
||||
const cached = uni.getStorageSync(getOverrideStorageKey(theme));
|
||||
if (cached && cached.map) {
|
||||
overrideCache[mk] = {
|
||||
version: cached.version || 0,
|
||||
map: cached.map || {},
|
||||
ts: cached.ts || 0,
|
||||
global_version: cached.global_version || 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把"单次 HTTP 响应"按 by_theme 拆到 3 个内存桶 + 持久化
|
||||
*/
|
||||
function applyRemoteResponse(d) {
|
||||
const byTheme = (d && d.by_theme && typeof d.by_theme === 'object') ? d.by_theme : {};
|
||||
const gv = (d && d.global_version) || 0;
|
||||
const ts = Date.now();
|
||||
|
||||
THEMES.forEach(theme => {
|
||||
const remoteMap = (byTheme[theme] && typeof byTheme[theme] === 'object') ? byTheme[theme] : {};
|
||||
const mk = getOverrideMemKey(theme);
|
||||
const baseCache = overrideCache[mk] || { version: 0, map: {}, ts: 0, global_version: 0 };
|
||||
const merged = Object.assign({}, baseCache.map, remoteMap);
|
||||
overrideCache[mk] = {
|
||||
version: gv,
|
||||
map: merged,
|
||||
ts,
|
||||
global_version: gv,
|
||||
};
|
||||
try { uni.setStorageSync(getOverrideStorageKey(theme), overrideCache[mk]); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取"3 个桶里最大的 global_version"(穿透缓存用)
|
||||
*/
|
||||
function maxCachedGlobalVersion() {
|
||||
let gv = 0;
|
||||
THEMES.forEach(t => {
|
||||
const mk = getOverrideMemKey(t);
|
||||
const c = overrideCache[mk];
|
||||
if (c && c.global_version > gv) gv = c.global_version;
|
||||
});
|
||||
return gv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 HTTP 拉齐 3 个 theme 桶
|
||||
* 这是 boot() / refresh() / onUniacidChange() 共用的"拉取入口"
|
||||
*
|
||||
* 关键: 1 次 HTTP, 拆 3 桶,避免对每个 theme 单独请求造成 3 倍浪费
|
||||
*
|
||||
* @returns {Promise<{ source:'cache'|'remote'|'local', ok:boolean, reason?:string, themes:string[] }>}
|
||||
*/
|
||||
async function loadAllOverrides() {
|
||||
if (SOURCE === 'local') return { source: 'local', ok: true, themes: THEMES };
|
||||
|
||||
// 并发去重:同一刻只允许一个在飞
|
||||
if (allInflight) return allInflight;
|
||||
|
||||
allInflight = (async () => {
|
||||
// 1) 先预热 storage 兜底
|
||||
THEMES.forEach(preheatFromStorage);
|
||||
|
||||
// 2) 一次 HTTP 拉齐 3 桶
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
http.sendRequest({
|
||||
url: REMOTE.overridesUrl || '/api/theme/overrides',
|
||||
data: {
|
||||
global_version: maxCachedGlobalVersion(),
|
||||
},
|
||||
async: false,
|
||||
}),
|
||||
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
|
||||
]);
|
||||
|
||||
if (!res || res.code !== 0 || !res.data) {
|
||||
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
|
||||
setRemoteStatus('fail', { reason, theme: 'all' });
|
||||
return { source: 'local', ok: false, reason, themes: THEMES };
|
||||
}
|
||||
|
||||
applyRemoteResponse(res.data);
|
||||
setRemoteStatus('ok', { theme: 'all' });
|
||||
emitUpdated('all');
|
||||
return { source: 'remote', ok: true, themes: THEMES };
|
||||
} catch (e) {
|
||||
setRemoteStatus('fail', { reason: 'exception', error: String(e && e.message || e), theme: 'all' });
|
||||
return { source: 'local', ok: false, reason: 'exception', themes: THEMES };
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await allInflight;
|
||||
} finally {
|
||||
allInflight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发"主题已更新"事件
|
||||
* 注意:Vuex 的 themeColorSet action 已经在 setThemeStyle 后跑过,
|
||||
* CSS 变量已挂到页面 wrapper 上,组件会自动刷新。
|
||||
* 这里只发通知,方便业务代码(如自定义 JS 直读 themeStyle 的页面)主动 re-render。
|
||||
*/
|
||||
function emitUpdated(theme) {
|
||||
try { uni.$emit && uni.$emit('theme:updated', { theme }); } catch (e) {}
|
||||
}
|
||||
|
||||
// 远端全量 preset 持久化 key: <storagePresetsPrefix><uniacid>_<appType>
|
||||
function getPresetsStorageKey() {
|
||||
const u = getCurrentUniacid();
|
||||
const a = getCurrentAppType();
|
||||
return `${REMOTE.storagePresetsPrefix || 'THEME_PRESETS_'}${u}_${a}`;
|
||||
}
|
||||
|
||||
// 远端全量 preset 内存 cache key
|
||||
function getPresetsMemKey() {
|
||||
return `${getCurrentUniacid()}_${getCurrentAppType()}`;
|
||||
}
|
||||
|
||||
// 从 storage 预热全量 preset 到内存(首屏兜底)
|
||||
function preheatPresetsFromStorage() {
|
||||
const mk = getPresetsMemKey();
|
||||
if (presetsCache[mk] && Object.keys(presetsCache[mk]).length) return;
|
||||
try {
|
||||
const cached = uni.getStorageSync(getPresetsStorageKey());
|
||||
if (cached && typeof cached === 'object') {
|
||||
presetsCache[mk] = cached;
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 HTTP 拉齐 8 个 preset(remote-only 模式)
|
||||
* 关键: 不阻塞首屏,失败静默. store/index.js 的 applyThemeWithRemote 会优先用
|
||||
* 内存缓存(由 loadAllPresets 写入), 缓存未命中时回退到 webpack stub(只含 default).
|
||||
*
|
||||
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
|
||||
*/
|
||||
async function loadAllPresets() {
|
||||
if (SOURCE === 'local') return { source: 'skipped', ok: true, presets: {} };
|
||||
if (!REMOTE.presetsUrl) return { source: 'skipped', ok: false, reason: 'no-url' };
|
||||
|
||||
// 并发去重
|
||||
if (presetsInflight) return presetsInflight;
|
||||
|
||||
presetsInflight = (async () => {
|
||||
// 1) 先预热 storage 兜底
|
||||
preheatPresetsFromStorage();
|
||||
|
||||
// 2) 一次 HTTP 拉齐 8 个 preset
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
http.sendRequest({
|
||||
url: REMOTE.presetsUrl,
|
||||
data: {},
|
||||
async: false,
|
||||
}),
|
||||
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
|
||||
]);
|
||||
|
||||
if (!res || res.code !== 0 || !res.data) {
|
||||
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
|
||||
console.warn('[theme-presets] remote load fail, keep stub:', reason);
|
||||
return { source: 'local', ok: false, reason };
|
||||
}
|
||||
|
||||
// 3) 解析响应. 期望格式: { by_preset: { default: {...}, green: {...}, ... } }
|
||||
const byPreset = (res.data.by_preset && typeof res.data.by_preset === 'object') ? res.data.by_preset : null;
|
||||
if (!byPreset) {
|
||||
console.warn('[theme-presets] response missing by_preset');
|
||||
return { source: 'local', ok: false, reason: 'parse' };
|
||||
}
|
||||
|
||||
// 4) 写入内存缓存 + 持久化
|
||||
const mk = getPresetsMemKey();
|
||||
presetsCache[mk] = byPreset;
|
||||
try { uni.setStorageSync(getPresetsStorageKey(), byPreset); } catch (e) {}
|
||||
|
||||
// 5) 通知已更新
|
||||
try { uni.$emit && uni.$emit('theme:presets-updated'); } catch (e) {}
|
||||
|
||||
return { source: 'remote', ok: true, presets: byPreset };
|
||||
} catch (e) {
|
||||
console.warn('[theme-presets] remote load exception:', e && e.message);
|
||||
return { source: 'local', ok: false, reason: 'exception' };
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await presetsInflight;
|
||||
} finally {
|
||||
presetsInflight = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出主体
|
||||
export default {
|
||||
SOURCE,
|
||||
|
||||
/**
|
||||
* 阻塞式预热:App.onLaunch 在 Lang.boot 后调用
|
||||
* 失败静默,不阻塞首屏
|
||||
*
|
||||
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:string, reason?:string, themes:string[]}>}
|
||||
*/
|
||||
async boot() {
|
||||
if (SOURCE === 'local') {
|
||||
return { ok: true, source: 'local', status: remoteStatus, themes: THEMES };
|
||||
}
|
||||
|
||||
const bootTimeout = REMOTE.bootTimeout || 2000;
|
||||
const t = new Promise((r) => setTimeout(() => r({ __timeout: true }), bootTimeout));
|
||||
|
||||
// 切租户检测
|
||||
const currentUid = getCurrentUniacid();
|
||||
try {
|
||||
const lastUid = uni.getStorageSync('theme_last_uniacid');
|
||||
if (lastUid && String(lastUid) !== String(currentUid)) {
|
||||
this.onUniacidChange();
|
||||
}
|
||||
uni.setStorageSync('theme_last_uniacid', currentUid);
|
||||
} catch (e) {}
|
||||
|
||||
const result = await Promise.race([loadAllOverrides(), t]);
|
||||
if (result && result.__timeout) {
|
||||
return { ok: false, source: 'timeout', status: 'fail', reason: 'timeout', themes: THEMES };
|
||||
}
|
||||
return {
|
||||
ok: remoteStatus === 'ok',
|
||||
source: remoteStatus === 'ok' ? 'remote' : 'local',
|
||||
status: remoteStatus,
|
||||
themes: THEMES,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 主动重新拉取(管理后台改了之后 EventBus 通知触发)
|
||||
* @param {boolean} [force=false] true = 绕过缓存强刷
|
||||
*/
|
||||
async refresh(force = false) {
|
||||
if (force) {
|
||||
// 强刷:清掉内存 + 持久化缓存,逼 loadAllOverrides 走远端
|
||||
THEMES.forEach(t => {
|
||||
const mk = getOverrideMemKey(t);
|
||||
if (overrideCache[mk]) overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
|
||||
try { uni.removeStorageSync(getOverrideStorageKey(t)); } catch (e) {}
|
||||
});
|
||||
}
|
||||
return loadAllOverrides();
|
||||
},
|
||||
|
||||
/**
|
||||
* 切租户后清旧缓存
|
||||
*/
|
||||
onUniacidChange() {
|
||||
if (SOURCE === 'local') return;
|
||||
if (REMOTE.refreshOnUniacidChange === false) return;
|
||||
THEMES.forEach(t => {
|
||||
const sk = getOverrideStorageKey(t);
|
||||
try { uni.removeStorageSync(sk); } catch (e) {}
|
||||
delete overrideCache[getOverrideMemKey(t)];
|
||||
});
|
||||
// 同步清空全量 preset 缓存(remote-only 模式)
|
||||
const psk = getPresetsStorageKey();
|
||||
try { uni.removeStorageSync(psk); } catch (e) {}
|
||||
delete presetsCache[getPresetsMemKey()];
|
||||
loadAllOverrides();
|
||||
},
|
||||
|
||||
/**
|
||||
* 取某个 theme 桶(同步:有缓存返缓存;无缓存时该桶返回 {})
|
||||
* store/index.js 的 applyThemeWithRemote action 直接用
|
||||
* @param {'brand'|'light'|'dark'} theme
|
||||
* @returns {object} 扁平 KV map (没有时返回 {})
|
||||
*/
|
||||
getByTheme(theme) {
|
||||
const mk = getOverrideMemKey(theme);
|
||||
const cached = overrideCache[mk];
|
||||
return (cached && cached.map) ? cached.map : {};
|
||||
},
|
||||
|
||||
/**
|
||||
* 取所有 theme 桶
|
||||
* @returns {{ brand: object, light: object, dark: object }}
|
||||
*/
|
||||
getAll() {
|
||||
return {
|
||||
brand: this.getByTheme('brand'),
|
||||
light: this.getByTheme('light'),
|
||||
dark: this.getByTheme('dark'),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 取全量 preset 内存缓存(remote-only 模式)
|
||||
* store/index.js 的 applyThemeWithRemote 通过这个取到远端拉的全量 preset
|
||||
* @returns {{ [presetName: string]: object }}
|
||||
*/
|
||||
getAllPresets() {
|
||||
const mk = getPresetsMemKey();
|
||||
return presetsCache[mk] || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* 主动拉全量 preset(remote-only 模式)
|
||||
* - 失败静默, 不抛错
|
||||
* - 内存 + 持久化双写
|
||||
* - 拉成功后发 uni.$emit('theme:presets-updated')
|
||||
*
|
||||
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
|
||||
*/
|
||||
loadAllPresets,
|
||||
|
||||
/**
|
||||
* 当前远端状态
|
||||
*/
|
||||
getRemoteStatus() { return remoteStatus; },
|
||||
|
||||
/**
|
||||
* 订阅远端状态变化
|
||||
* @param {(status:string, detail:object) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
onRemoteStatusChange(fn) {
|
||||
if (typeof fn !== 'function') return () => {};
|
||||
remoteStatusListeners.push(fn);
|
||||
try { fn(remoteStatus, { at: remoteStatusAt, source: SOURCE }); } catch (e) {}
|
||||
return () => {
|
||||
const i = remoteStatusListeners.indexOf(fn);
|
||||
if (i >= 0) remoteStatusListeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
|
||||
// 内部测试/调试用
|
||||
__debug_overrideCache: overrideCache,
|
||||
__debug_loadAllOverrides: loadAllOverrides,
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* 将常用的Uniapp提供的函数,存放到这里,按需引用
|
||||
*/
|
||||
|
||||
import lang from './lang.js';
|
||||
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
* @param {Exception} err
|
||||
* @param {Boolean} useModal
|
||||
*/
|
||||
const showError = (err, useModal = false) => {
|
||||
const content = err?.message || err?.errMsg || err?.toString();
|
||||
if (!useModal) {
|
||||
uni.showToast({
|
||||
title: content,
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
});
|
||||
} else {
|
||||
uni.showModal({
|
||||
title: lang.lang('uniappUtils.errorTip'),
|
||||
content,
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打电话
|
||||
* @param {string} mobile 电话号码
|
||||
*/
|
||||
export const makePhoneCall = (mobile) => {
|
||||
try {
|
||||
uni.makePhoneCall({
|
||||
phoneNumber: `${mobile}`,
|
||||
success(e) {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拷贝文本(返回 Promise)
|
||||
* @param {*} text
|
||||
* @param {*} options
|
||||
* @returns {Promise} 返回 Promise,成功时 resolve,失败时 reject
|
||||
*/
|
||||
export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 输入验证
|
||||
if (!text && text !== '') {
|
||||
const error = new Error(lang.lang('uniappUtils.copyTextEmpty'));
|
||||
showError(error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 超时监测
|
||||
const timeoutId = setTimeout(() => {
|
||||
let error = new Error(lang.lang('uniappUtils.copyTimeout'));
|
||||
// #ifdef MP-WEIXIN
|
||||
error = new Error([
|
||||
lang.lang('uniappUtils.copyTimeoutTitle'),
|
||||
lang.lang('uniappUtils.copyTimeoutReason'),
|
||||
lang.lang('uniappUtils.wechatClipboardReason1'),
|
||||
lang.lang('uniappUtils.wechatClipboardReason2')
|
||||
].join('\r\n'));
|
||||
// #endif
|
||||
showError(error, true);
|
||||
reject(error);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
uni.setClipboardData({
|
||||
data: `${text}`,
|
||||
success: (res) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
try {
|
||||
if (copySuccess) {
|
||||
uni.showToast({
|
||||
title: copySuccess,
|
||||
icon: 'success',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
|
||||
resolve(res);
|
||||
},
|
||||
fail: (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
try {
|
||||
uni.showToast({
|
||||
title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'),
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
showError(err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拷贝文本(回调形式,兼容旧代码)
|
||||
* @param {*} text
|
||||
* @param {*} options
|
||||
* @param {Function} callback 回调函数,接收 (success, error) 参数
|
||||
*/
|
||||
export const copyText = (text, options = {}, callback) => {
|
||||
copyTextAsync(text, options)
|
||||
.then(res => {
|
||||
if (callback) callback(true, null);
|
||||
})
|
||||
.catch(err => {
|
||||
if (callback) callback(false, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开定位
|
||||
* @param {Object} options
|
||||
*/
|
||||
export const openLocation = ({ latitude, longitude, name } = {}) => {
|
||||
try {
|
||||
uni.openLocation({
|
||||
latitude,
|
||||
longitude,
|
||||
name,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
}
|
||||
@@ -1,814 +0,0 @@
|
||||
/**
|
||||
* util-navigate.js
|
||||
*
|
||||
* 跳转链路专用的工具集 — 从 util.js 拆分出来
|
||||
*
|
||||
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 跳转链路):
|
||||
* - _toParamValueString : 把任意值序列化成可作为 URL 参数的字符串
|
||||
* - parsePageParams : 把 page_params 还原成对象(JSON.parse 安全封装)
|
||||
* - resolveLinkUrl : 解析 link 对象得到最终可跳转的 URL
|
||||
* - cleanParamUrls : 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段
|
||||
* - normalizeUrl : 严格规范化 URL(不补协议/不补 host)
|
||||
* - appendQueryParams : 给 URL 追加 query 参数
|
||||
* - getPlatform : 跨平台检测(h5 / mp-weixin / mp-harmony / quickapp)
|
||||
* - shouldEncodeUrl : 是否需要主动 encodeURIComponent(4 平台都 true)
|
||||
* - _safeEncodeForPlatform : 跨平台统一的 safeEncode(decode roundtrip + encode 一次)
|
||||
* - _navigateTo : 跨平台跳转 API 调度
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 不依赖 util.js,避免循环依赖
|
||||
* 2. 全部为普通函数(不挂在 default export 对象上),方便 tree-shaking 和测试
|
||||
* 3. 行为完全兼容 util.js 旧版,util.js 通过 import 把这些方法挂载到 default export 上
|
||||
* (所有调用方 this.$util.cleanParamUrls / this.$util.redirectTo 等都不需要改)
|
||||
*
|
||||
* 跨平台编码 / 跳转策略文档见 _safeEncodeForPlatform / _navigateTo 的注释
|
||||
*/
|
||||
|
||||
// =============================================================
|
||||
// 基础工具
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 把任意值序列化成可作为 URL 参数的字符串。
|
||||
* - 对象/数组: JSON.stringify
|
||||
* - null/undefined: ''
|
||||
* - 其他: String(v)
|
||||
*
|
||||
* @param {*} v
|
||||
* @returns {string}
|
||||
*/
|
||||
export function _toParamValueString(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
|
||||
* 容错策略: 任何解析失败都安全降级到 defaultValue,不抛异常。
|
||||
*
|
||||
* @example
|
||||
* parsePageParams('{"a":1}') // { a: 1 }
|
||||
* parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回)
|
||||
* parsePageParams('[object Object]') // {} (老 bug 残留安全降级)
|
||||
* parsePageParams('') // {}
|
||||
* parsePageParams(null) // {}
|
||||
* parsePageParams(undefined) // {}
|
||||
* parsePageParams('not json', {x:1}) // { x: 1 } (解析失败走默认值)
|
||||
*
|
||||
* @param {*} raw
|
||||
* @param {Object} [defaultValue={}]
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function parsePageParams(raw, defaultValue = {}) {
|
||||
if (raw == null) return Object.assign({}, defaultValue);
|
||||
if (typeof raw === 'object') return raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? parsed
|
||||
: Object.assign({}, defaultValue);
|
||||
} catch (e) {
|
||||
return Object.assign({}, defaultValue);
|
||||
}
|
||||
}
|
||||
return Object.assign({}, defaultValue);
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// resolveLinkUrl
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 解析 link 对象得到最终可跳转的 URL
|
||||
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
|
||||
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 二次编码
|
||||
* 优先级: page_params > wap_url query (新机制优先)
|
||||
*
|
||||
* @param {Object} link
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveLinkUrl(link) {
|
||||
if (!link) return null;
|
||||
// 0. 把 wap_url 中的 query 合并到 page_params
|
||||
if (link.wap_url) {
|
||||
const decodedWap = decodeEntity(link.wap_url);
|
||||
const queryIdx = decodedWap.indexOf('?');
|
||||
if (queryIdx !== -1) {
|
||||
const path = decodedWap.substring(0, queryIdx);
|
||||
const queryStr = decodedWap.substring(queryIdx + 1);
|
||||
const urlParams = {};
|
||||
queryStr.split('&').forEach((kv) => {
|
||||
if (!kv) return;
|
||||
const eqIdx = kv.indexOf('=');
|
||||
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
|
||||
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
|
||||
if (k) urlParams[k] = v;
|
||||
});
|
||||
const pageParams = parsePageParams(link.page_params);
|
||||
link.page_params = Object.assign({}, urlParams, pageParams);
|
||||
link.wap_url = path;
|
||||
}
|
||||
}
|
||||
// 1. 优先使用 page_key(新机制)
|
||||
if (link.page_key) {
|
||||
// 这里原本会调 resolvePageUrl(link.page_key, params),
|
||||
// 但 util-navigate.js 不依赖 util.js,因此返回 link.wap_url
|
||||
// (util.js 在 import 此模块后会用 page_key 重新解析)
|
||||
const params = parsePageParams(link.page_params);
|
||||
// 注: 完整 page_key 解析留给 util.js (它有 resolvePageUrl)
|
||||
// 这里只做"wap_url 已被 resolveLinkUrl 处理过" 的返回
|
||||
}
|
||||
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
|
||||
if (link.wap_url) {
|
||||
return link.wap_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* decodeEntity — HTML 实体解码
|
||||
* (从 util.js 顶层 export 搬过来, 跟其他跳转工具一起)
|
||||
*
|
||||
* 解码 &xxx; / &#xN; 等 HTML 实体
|
||||
* 容错: 任何解析失败都返回原文
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {boolean} [amp=true] 是否解码 &
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decodeEntity(str, amp) {
|
||||
const html4Entities = {
|
||||
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥',
|
||||
brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«',
|
||||
not: '¬', shy: '®', macr: '¯', deg: '°', plusmn: '±', sup2: '²',
|
||||
sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸',
|
||||
sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾',
|
||||
iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä',
|
||||
aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê',
|
||||
euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð',
|
||||
ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö',
|
||||
times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý',
|
||||
thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ',
|
||||
delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι',
|
||||
kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο',
|
||||
pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ',
|
||||
phi: 'φ', chi: 'ψ', omega: 'ω',
|
||||
};
|
||||
amp = amp === undefined ? true : amp;
|
||||
let i = str.indexOf('&');
|
||||
while (i !== -1) {
|
||||
const j = str.indexOf(';', i + 3);
|
||||
let code;
|
||||
if (j === -1) break;
|
||||
if (str[i + 1] === '#') {
|
||||
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j));
|
||||
if (!isNaN(code)) {
|
||||
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1);
|
||||
}
|
||||
} else {
|
||||
code = str.substring(i + 1, j);
|
||||
if (html4Entities[code] || (code === 'amp' && amp)) {
|
||||
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1);
|
||||
}
|
||||
}
|
||||
i = str.indexOf('&', i + 1);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// cleanParamUrls
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段:
|
||||
* - 去除反引号 / 各种空白 / 零宽字符
|
||||
* - 修复 scheme 后多余斜杠(https:////host → https://host)
|
||||
*
|
||||
* "看起来像 URL" 的判定: 含 `://`(大小写不敏感)
|
||||
* - 命中: 走清理
|
||||
* - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤)
|
||||
*
|
||||
* 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖
|
||||
*
|
||||
* @param {*} value 任意值
|
||||
* @returns {*} 与输入同结构的清理后值
|
||||
*/
|
||||
export function cleanParamUrls(value) {
|
||||
// 内部递归函数,通过闭包引用自身,避免 this 绑定问题
|
||||
const _clean = (v) => {
|
||||
if (v === null || v === undefined) return v;
|
||||
if (typeof v === 'string') {
|
||||
if (/:\/\//i.test(v)) {
|
||||
return v
|
||||
.split('').filter((ch) =>
|
||||
ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r',
|
||||
).join('')
|
||||
.replace(/^(https?:\/\/)\/+/i, '$1');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((item) => _clean(item));
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v)) {
|
||||
out[k] = _clean(v[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return _clean(value);
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// normalizeUrl
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 严格 URL 规范化(只规范化已有部分,绝不自动补充)
|
||||
*
|
||||
* 策略:
|
||||
* 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底)
|
||||
* 2) 不补 https:// — 如果原串没有协议,保持没有
|
||||
* 3) 不补 host — 如果原串没有 host,保持没有
|
||||
* 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序
|
||||
* 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 ''
|
||||
*
|
||||
* @param {string} urlString
|
||||
* @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 ''
|
||||
*/
|
||||
export function normalizeUrl(urlString) {
|
||||
if (!urlString || typeof urlString !== 'string') return '';
|
||||
|
||||
// 1) 预清理: 反引号 / 各种空白 / BOM / 零宽字符
|
||||
let url = urlString
|
||||
.split('').filter((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code <= 0x20) return false;
|
||||
if (ch === '`') return false;
|
||||
if (code === 0x3000) return false;
|
||||
if (code === 0xFEFF || code === 0x200B ||
|
||||
code === 0x200C || code === 0x200D ||
|
||||
code === 0x2060) return false;
|
||||
return true;
|
||||
}).join('');
|
||||
|
||||
// 2) 解析协议(可选,允许缺失)
|
||||
const protocolMatch = url.match(/^(https?):\/\//i);
|
||||
let protocol = '';
|
||||
let rest = url;
|
||||
if (protocolMatch) {
|
||||
protocol = protocolMatch[1].toLowerCase() + '://';
|
||||
rest = url.substring(protocolMatch[0].length);
|
||||
// scheme 后不能紧跟 '/',即不允许 https:////host
|
||||
if (rest.charAt(0) === '/') {
|
||||
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
rest = url;
|
||||
}
|
||||
|
||||
// 3) 解析 hash
|
||||
const hashIndex = rest.indexOf('#');
|
||||
let hash = '';
|
||||
let mainPart = rest;
|
||||
if (hashIndex !== -1) {
|
||||
hash = rest.substring(hashIndex);
|
||||
mainPart = rest.substring(0, hashIndex);
|
||||
}
|
||||
|
||||
// 4) 解析 query
|
||||
const queryIndex = mainPart.indexOf('?');
|
||||
let query = '';
|
||||
let pathPart = mainPart;
|
||||
if (queryIndex !== -1) {
|
||||
query = mainPart.substring(queryIndex); // 包含 '?'
|
||||
pathPart = mainPart.substring(0, queryIndex);
|
||||
}
|
||||
|
||||
// 5) 解析 host
|
||||
let hostname = '';
|
||||
let port = '';
|
||||
let pathname = pathPart;
|
||||
if (protocol) {
|
||||
const firstSlash = pathPart.indexOf('/');
|
||||
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
|
||||
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash);
|
||||
if (hostPort) {
|
||||
const colonIdx = hostPort.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
hostname = hostPort.substring(0, colonIdx);
|
||||
port = hostPort.substring(colonIdx + 1);
|
||||
} else {
|
||||
hostname = hostPort;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 规范化 host
|
||||
let lowerHostname = '';
|
||||
if (hostname) {
|
||||
lowerHostname = hostname.toLowerCase();
|
||||
if ((protocol === 'http://' && port === '80') ||
|
||||
(protocol === 'https://' && port === '443')) {
|
||||
port = '';
|
||||
}
|
||||
if (/[\/\s]/.test(lowerHostname)) {
|
||||
console.warn('[normalizeUrl] host 含非法字符:', urlString);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 规范化 pathname
|
||||
let normalizedPath = pathname;
|
||||
if (normalizedPath) {
|
||||
const segments = normalizedPath.split('/');
|
||||
const resolved = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (seg === '' || seg === '.') {
|
||||
if (i === 0) resolved.push('');
|
||||
continue;
|
||||
}
|
||||
if (seg === '..') {
|
||||
if (resolved.length > 1) resolved.pop();
|
||||
continue;
|
||||
}
|
||||
resolved.push(seg);
|
||||
}
|
||||
normalizedPath = resolved.join('/') || '/';
|
||||
}
|
||||
|
||||
// 8) 规范化 query
|
||||
let normalizedQuery = '';
|
||||
if (query) {
|
||||
const qs = query.substring(1);
|
||||
if (qs) {
|
||||
const pairs = qs.split('&').map((pair) => {
|
||||
const eqIdx = pair.indexOf('=');
|
||||
return eqIdx === -1
|
||||
? { k: pair, v: '' }
|
||||
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
|
||||
});
|
||||
pairs.sort((a, b) => a.k.localeCompare(b.k));
|
||||
normalizedQuery = '?' + pairs.map((p) => p.k + '=' + p.v).join('&');
|
||||
}
|
||||
}
|
||||
|
||||
// 9) 拼回
|
||||
const portStr = port ? ':' + port : '';
|
||||
const hostPart = lowerHostname ? lowerHostname + portStr : '';
|
||||
const schemeAndHost = protocol + hostPart;
|
||||
return schemeAndHost + normalizedPath + normalizedQuery + hash;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// extractInternalAppPath
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 从一个 URL 中提取"本 App 内部页面路径"。
|
||||
*
|
||||
* 背景 / 修复的 bug:
|
||||
* 后台装修数据里,内部页面常被存成【绝对 URL】,例如
|
||||
* https://host/hwappx/common/pages_tool/form/formdata?id=130
|
||||
* diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是:
|
||||
* - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失
|
||||
* - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view
|
||||
* 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径,
|
||||
* 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。
|
||||
*
|
||||
* 判定规则(双闸门,避免误伤真正的外部站点):
|
||||
* 闸门 1 —— host 校验(相对路径直接放行):
|
||||
* 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感);
|
||||
* 否则返回 null(视为真外链, 维持原逻辑)。
|
||||
* ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链,
|
||||
* 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。
|
||||
* 闸门 2 —— 路径校验:
|
||||
* pathname 中出现以 App 页面根开头的段(pages / pages_xxx),
|
||||
* 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回;
|
||||
* 未命中返回 null。
|
||||
*
|
||||
* @example
|
||||
* const H = ['xcx6.aigc-quickapp.com']
|
||||
* extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H)
|
||||
* // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原)
|
||||
* extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H)
|
||||
* // null (子域不同 → 维持外链)
|
||||
* extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H)
|
||||
* // null (外域名 → 维持外链,不误伤)
|
||||
* extractInternalAppPath('/pages_goods/detail?id=9', H)
|
||||
* // '/pages_goods/detail?id=9' (相对路径 → 内部)
|
||||
*
|
||||
* @param {string} url 绝对或相对 URL
|
||||
* @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取);
|
||||
* 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。
|
||||
* @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null
|
||||
*/
|
||||
export function extractInternalAppPath(url, allowedHosts) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
let rest = url;
|
||||
// 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部)
|
||||
const hostMatch = rest.match(/^https?:\/\/([^/]*)/i);
|
||||
if (hostMatch) {
|
||||
if (allowedHosts !== true) {
|
||||
const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, '');
|
||||
const allow = _normalizeHostList(allowedHosts);
|
||||
if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改
|
||||
}
|
||||
rest = rest.substring(hostMatch[0].length);
|
||||
}
|
||||
// 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx)
|
||||
const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i);
|
||||
if (!pageMatch) return null;
|
||||
const internal = rest.substring(pageMatch.index);
|
||||
return internal || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。
|
||||
* 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。
|
||||
* @param {string|string[]} hosts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function _normalizeHostList(hosts) {
|
||||
if (!hosts) return [];
|
||||
const arr = Array.isArray(hosts) ? hosts : [hosts];
|
||||
const out = [];
|
||||
for (const h of arr) {
|
||||
if (!h || typeof h !== 'string') continue;
|
||||
let s = h.trim().toLowerCase();
|
||||
const m = s.match(/^https?:\/\/([^/]*)/i);
|
||||
if (m) s = m[1] || '';
|
||||
else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path
|
||||
s = s.replace(/:\d+$/, ''); // 去端口
|
||||
if (s) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。
|
||||
*
|
||||
* 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode):
|
||||
* - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致)
|
||||
* - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view)
|
||||
* - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上
|
||||
*
|
||||
* @param {Object} link
|
||||
* @returns {'internal'|'online'|'auto'}
|
||||
*/
|
||||
export function resolveNavMode(link) {
|
||||
if (!link || typeof link !== 'object') return 'auto';
|
||||
const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||||
const raw = link.open_mode != null ? link.open_mode
|
||||
: (link.openMode != null ? link.openMode
|
||||
: (pp.open_mode != null ? pp.open_mode : pp.openMode));
|
||||
const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : '';
|
||||
if (m === 'internal' || m === 'spa') return 'internal';
|
||||
if (m === 'online' || m === 'web') return 'online';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl
|
||||
// =============================================================
|
||||
// pickCurrentUniacid
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 从两个候选 uniacid 源中选出"系统当前 uniacid"。
|
||||
*
|
||||
* 用法(当前 diyRedirectTo 中的实际调用, 必须与 http.js 行 168 保持一致):
|
||||
* pickCurrentUniacid(
|
||||
* uni.getStorageSync('uniacid'), // 持久化, 登录/初始化写入
|
||||
* Config.uniacid, // 编译期兜底
|
||||
* )
|
||||
*
|
||||
* 优先级: 第一个参数非 0 → 选第一个; 否则选第二个非 0 值; 都没有 → 0。
|
||||
* 0 视为"无 uniacid": 业务上 uniacid 不会是 0, parseInt 后为 0 也算无效。
|
||||
*
|
||||
* ★ 注意: 故意【不读 store.state.uniacid】。当前代码库里 store.state 上从未设置
|
||||
* uniacid 字段(http.js 只用 storage/Config), 若引入 store 分支会出现
|
||||
* "守卫用 A 真值, http 请求用 B 真值"的不一致。
|
||||
*
|
||||
* @param {*} primaryValue 第一候选(运行时持久化值)
|
||||
* @param {*} secondaryValue 第二候选(编译期兜底)
|
||||
* @returns {number} 选出的 uniacid; 都无效时返回 0
|
||||
*/
|
||||
export function pickCurrentUniacid(primaryValue, secondaryValue) {
|
||||
const a = parseInt(primaryValue, 10);
|
||||
if (a) return a; // 非 0 真值
|
||||
const b = parseInt(secondaryValue, 10);
|
||||
if (b) return b;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// hasStaleUniacidInUrl
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 检测 link.wap_url 中【URL 原生】的 uniacid query 与系统当前 uniacid 是否一致。
|
||||
*
|
||||
* 用途: uniacid 是平台租户 ID, 会随租户迁移 / 平台调整而变化。
|
||||
* - 如果数据里的 wap_url 自带的 uniacid 与当前系统不一致,
|
||||
* 说明这份装修数据可能是历史遗留或别的租户的(脏数据),
|
||||
* 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
|
||||
* - 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
|
||||
*
|
||||
* 关键: 必须【在 resolveLinkUrl 抽走 query 之前】判断, 否则 URL 里的 uniacid 已经
|
||||
* 被搬到 page_params 里, 分不清"URL 原生"还是"外加的"。
|
||||
*
|
||||
* 比较规则: 两侧 parseInt 后比较, 兼容 '2482' vs 2482、URL 编码、空值。
|
||||
*
|
||||
* @param {string} wap_url link.wap_url (resolveLinkUrl 之前的原始值)
|
||||
* @param {string|number|null} currentUniacid 系统当前的 uniacid
|
||||
* (0 / null / '' / undefined 一律视为"无当前 uniacid", 返回 false — 让上游决定怎么做)
|
||||
* @returns {boolean} true = URL 原生 uniacid 与当前不一致(脏数据, 该强制走线上)
|
||||
*/
|
||||
export function hasStaleUniacidInUrl(wap_url, currentUniacid) {
|
||||
if (!wap_url || typeof wap_url !== 'string') return false;
|
||||
if (currentUniacid == null || currentUniacid === '' || currentUniacid === 0) return false;
|
||||
const m = wap_url.match(/[?&]uniacid=([^&#]*)/i);
|
||||
if (!m) return false;
|
||||
let urlUniacid;
|
||||
try { urlUniacid = decodeURIComponent(m[1]); } catch (e) { urlUniacid = m[1]; }
|
||||
const cur = String(currentUniacid);
|
||||
const a = parseInt(urlUniacid, 10);
|
||||
const b = parseInt(cur, 10);
|
||||
// 两侧都能 parse 成数字时按数字比; 否则按字符串比
|
||||
if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b;
|
||||
return urlUniacid !== cur;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// appendQueryParams
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 给 URL 追加 query 参数(已存在的 key 会被覆盖)
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {Object} params
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appendQueryParams(url, params) {
|
||||
if (!url) return '';
|
||||
if (!params) return url;
|
||||
|
||||
const hashIndex = url.indexOf('#');
|
||||
let hash = '';
|
||||
let targetUrl = url;
|
||||
if (hashIndex !== -1) {
|
||||
hash = url.substring(hashIndex);
|
||||
targetUrl = url.substring(0, hashIndex);
|
||||
}
|
||||
|
||||
const separator = targetUrl.includes('?') ? '&' : '?';
|
||||
const queryString = Object.entries(params)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
|
||||
.map(([key, value]) => `${key}=${_toParamValueString(value)}`)
|
||||
.join('&');
|
||||
|
||||
return targetUrl + separator + queryString + hash;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 跨平台检测 / 编码 / 跳转
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 平台检测 — 4 个目标平台:
|
||||
* - 'h5' 浏览器
|
||||
* - 'mp-weixin' 微信小程序
|
||||
* - 'mp-harmony' 鸿蒙元服务
|
||||
* - 'quickapp' 华为快应用 (实际是 H5 嵌入快应用 Webview)
|
||||
*
|
||||
* 测试覆盖: util.js 在 import 后,可在 default export 上设 __platform 强制指定
|
||||
*/
|
||||
export function getPlatform() {
|
||||
// 1) 测试覆盖: 通过模块级 _testPlatform 注入
|
||||
if (typeof globalThis !== 'undefined' && globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__) {
|
||||
return globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__;
|
||||
}
|
||||
|
||||
// 2) 编译时变量
|
||||
// #ifdef MP-WEIXIN
|
||||
return 'mp-weixin'
|
||||
// #endif
|
||||
// #ifdef MP-HARMONY
|
||||
return 'mp-harmony'
|
||||
// #endif
|
||||
|
||||
// 3) 运行时
|
||||
if (navigator?.userAgent?.includes('mode-quickapp')) { // H5网页,如何判断自己在华为快应用的Webview中
|
||||
return 'quickapp'
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
try {
|
||||
var ua = navigator?.userAgent;
|
||||
if (ua) {
|
||||
// 华为快应用内嵌 WebView 的 UA 关键字:HarmonyQuickApp / HuaweiQuickApp / HQABridge / QuickApp
|
||||
const isQuickapp = /HarmonyQuickApp|HuaweiQuickApp|HQABridge|QuickApp/i.test(ua);
|
||||
if (isQuickapp) {
|
||||
return 'quickapp'
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
if (typeof process !== 'undefined' && process.env && process.env.UNI_PLATFORM) {
|
||||
return process.env.UNI_PLATFORM;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.location !== 'undefined' && typeof uni === 'undefined') {
|
||||
return 'h5';
|
||||
}
|
||||
if (typeof uni !== 'undefined' && typeof uni.getSystemInfoSync === 'function') {
|
||||
try {
|
||||
const info = uni?.getSystemInfoSync();
|
||||
const p = (info && info.platform) || '';
|
||||
if (/harmony|ohos/i.test(p)) return 'mp-harmony';
|
||||
if (info.mp === true || /miniprogram/i.test(info.appName || '')) return 'mp-weixin';
|
||||
if (/quickapp|huawei/i.test(p)) return 'quickapp';
|
||||
return 'h5';
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 默认返回
|
||||
return 'h5';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是在华为快应用的WebView中
|
||||
* @returns
|
||||
*/
|
||||
export function isHarmonyQuickAppWebview() {
|
||||
return getPlatform() === 'quickapp'
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要 JS 侧主动 encodeURIComponent
|
||||
* 4 平台都需要(true)
|
||||
*/
|
||||
export function shouldEncodeUrl() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样
|
||||
*
|
||||
* 解决问题:
|
||||
* - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值
|
||||
* 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏),
|
||||
* 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。
|
||||
* - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。
|
||||
* 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。
|
||||
* - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。
|
||||
*
|
||||
* @param {string} s 已 decode roundtrip 后的原文
|
||||
* @returns {string}
|
||||
*/
|
||||
function _encodeUrlDelimiters(s) {
|
||||
// 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样
|
||||
return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c));
|
||||
}
|
||||
|
||||
export function _safeEncodeForPlatform(v) {
|
||||
const s = _toParamValueString(v);
|
||||
|
||||
// decode roundtrip: 兼容上游已 encode 过的入参(无论平台都做,避免双编码)
|
||||
let once = s;
|
||||
try {
|
||||
if (/%[0-9a-fA-F]{2}/.test(s)) {
|
||||
once = decodeURIComponent(s);
|
||||
}
|
||||
} catch (e) {
|
||||
once = s;
|
||||
}
|
||||
|
||||
const platform = getPlatform();
|
||||
|
||||
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
|
||||
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
|
||||
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
|
||||
if (['mp-weixin','mp-harmony'].includes(platform)) {
|
||||
return _encodeUrlDelimiters(once);
|
||||
}
|
||||
|
||||
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
|
||||
return encodeURIComponent(once);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨平台跳转 API 调度
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} args.to
|
||||
* @param {string} [args.mode='navigateTo']
|
||||
* @param {string} [args.platform] 不传则自动 getPlatform()
|
||||
* @returns {{ url: string, mode: string, api: string }}
|
||||
*/
|
||||
export function _navigateTo({ to, mode, platform } = {}) {
|
||||
const p = platform || getPlatform();
|
||||
const m = mode || 'navigateTo';
|
||||
|
||||
// H5: 用 window.location
|
||||
if (p === 'h5') {
|
||||
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
|
||||
console.warn('[navigate] H5 环境下没有 window.location');
|
||||
return { url: to, mode: m, api: 'noop' };
|
||||
}
|
||||
try {
|
||||
if (m === 'reLaunch' || m === 'redirectTo') {
|
||||
window.location.replace(to);
|
||||
} else {
|
||||
window.location.href = to;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[navigate] H5 跳转失败:', e);
|
||||
}
|
||||
return { url: to, mode: m, api: 'window.location' };
|
||||
}
|
||||
|
||||
// 微信 / 鸿蒙 / 快应用: 走对应的 uni API
|
||||
// (华为快应用实际是 H5 嵌入快应用 Webview, uni 全局可用)
|
||||
if (typeof uni === 'undefined') {
|
||||
console.warn('[navigate] 非 H5 环境下没有 uni 全局');
|
||||
return { url: to, mode: m, api: 'noop' };
|
||||
}
|
||||
|
||||
let api = 'navigateTo';
|
||||
switch (m) {
|
||||
case 'tabbar':
|
||||
case 'switchTab':
|
||||
// 鸿蒙元服务: uni.switchTab 静默失败, 用 reLaunch 兜底
|
||||
// (微信 / 快应用: 正常用 uni.switchTab)
|
||||
if (p === 'mp-harmony') {
|
||||
uni.reLaunch({ url: to });
|
||||
api = 'reLaunch';
|
||||
} else {
|
||||
uni.switchTab({ url: to });
|
||||
api = 'switchTab';
|
||||
}
|
||||
break;
|
||||
case 'redirectTo':
|
||||
uni.redirectTo({ url: to });
|
||||
api = 'redirectTo';
|
||||
break;
|
||||
case 'reLaunch':
|
||||
uni.reLaunch({ url: to });
|
||||
api = 'reLaunch';
|
||||
break;
|
||||
case 'navigateTo':
|
||||
default:
|
||||
uni.navigateTo({ url: to });
|
||||
api = 'navigateTo';
|
||||
break;
|
||||
}
|
||||
return { url: to, mode: m, api };
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 兼容: 把所有函数打包到一个对象(用于 util.js 挂载到 default export)
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 把所有跳转相关函数打包到一个对象, util.js 把它挂载到 default export
|
||||
* 上, 保持 this.$util.cleanParamUrls / this.$util.redirectTo 等 API 兼容
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.platform] 强制指定平台(测试用)
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createNavigateApi(opts = {}) {
|
||||
if (opts.platform) {
|
||||
// 测试用: 注入到 globalThis,getPlatform 会优先读
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = opts.platform;
|
||||
}
|
||||
}
|
||||
return {
|
||||
getPlatform,
|
||||
shouldEncodeUrl,
|
||||
_safeEncodeForPlatform,
|
||||
_navigateTo,
|
||||
cleanParamUrls,
|
||||
normalizeUrl,
|
||||
appendQueryParams,
|
||||
extractInternalAppPath,
|
||||
parsePageParams,
|
||||
resolveLinkUrl,
|
||||
decodeEntity,
|
||||
};
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
/**
|
||||
* util-tabbar.js
|
||||
*
|
||||
* TabBar / 子包路径相关工具集 — 从 util.js 拆分出来
|
||||
*
|
||||
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 的 tabBar 命中判断):
|
||||
* - resolvePageUrl : 根据 page_key + params 解析得到实际跳转 URL
|
||||
* - isPageInRouteList : 判断当前路由是否命中 page_routes 列表
|
||||
* - resolveLinkUrl : 解析 link 对象得到最终 URL(合并 wap_url query 进 page_params)
|
||||
* - systemTabBarList : 当前最新的 tabBar.list 配置
|
||||
* - adaptSubpackageUrl : 子包路径前缀映射(/pages/order/ → /pages_order/)
|
||||
* - _normalizePath : 归一化任意路径(私有,内部用)
|
||||
* - isTabBarRootPage : 判断给定路径是否是 TabBar 根页面
|
||||
* - isCurrentPageTabBarRoot : 判断**当前**页面是否是 TabBar 根页面(依赖 getCurrentPages/store)
|
||||
* - checkTabBarActive : 检查 tabBar 是否激活
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 不依赖 util.js,避免循环依赖
|
||||
* 2. 纯函数为主(isCurrentPageTabBarRoot 除外,它依赖 getCurrentPages 和 store,
|
||||
* 业务方在调用时手动注入 store 即可)
|
||||
* 3. PAGE_KEY_MAP / NO_TAB_PAGES / PAGE_TAB_MAP / TAB_BAR_KEYS 仍然在 util.js(业务配置)
|
||||
* util-tabbar.js 接受外部传入,提高复用性
|
||||
*/
|
||||
|
||||
import { _toParamValueString } from './util-navigate.js';
|
||||
|
||||
// =============================================================
|
||||
// PAGE_KEY → 实际 URL 解析
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 根据 page_key + params 解析得到实际跳转 URL
|
||||
* @param {string} page_key 页面语义常量, 如 PAGE_GOODS_LIST
|
||||
* @param {Object} params 页面参数(JSON 对象)
|
||||
* @param {Object} [pageKeyMap={}] page_key → 实际路径的映射表(由调用方注入,通常是 PAGE_KEY_MAP)
|
||||
* @returns {string|null} 实际 URL(已拼接参数)或 null(未匹配)
|
||||
*/
|
||||
export function resolvePageUrl(page_key, params = {}, pageKeyMap = {}) {
|
||||
if (!page_key || !pageKeyMap[page_key]) {
|
||||
return null;
|
||||
}
|
||||
let url = pageKeyMap[page_key];
|
||||
if (params && typeof params === 'object' && Object.keys(params).length > 0) {
|
||||
const parts = [];
|
||||
Object.keys(params).forEach(function (key) {
|
||||
const value = params[key];
|
||||
if (value === null || value === undefined || value === '') return;
|
||||
// 对象/数组走 JSON.stringify, 避免 String({}) 退化成 "[object Object]"
|
||||
// 消费端 resolveLinkUrl 已用 JSON.parse 还原
|
||||
const encodedValue = _toParamValueString(value);
|
||||
// H5: 浏览器不自动编码, 需要手动 encodeURIComponent
|
||||
// 小程序 / App: uni.navigateTo 会自动编码, 这里不能再 encode, 否则双重编码
|
||||
// #ifdef H5
|
||||
parts.push(key + '=' + encodeURIComponent(encodedValue));
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
parts.push(key + '=' + encodedValue);
|
||||
// #endif
|
||||
});
|
||||
if (parts.length > 0) {
|
||||
url = url + (url.indexOf('?') !== -1 ? '&' : '?') + parts.join('&');
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前路由是否命中 page_routes 列表
|
||||
* 支持 3 种写法, 可混合使用:
|
||||
* 1) PAGE_KEY_MAP 的 key → 如 "PAGE_INDEX"
|
||||
* 2) 实际页面路径 → 如 "/pages/index/index"
|
||||
* 3) 通配符路径 → 如 "/pages_tool/*" 或 "PAGE_ORDER_*"
|
||||
*
|
||||
* @param {string} currentRoute 当前页面路径(会自动补 / 前缀)
|
||||
* @param {string[]} routeList 后台配置的 page_routes 数组
|
||||
* @param {Object} [pageKeyMap={}] PAGE_KEY_MAP
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPageInRouteList(currentRoute, routeList, pageKeyMap = {}) {
|
||||
if (!currentRoute || !routeList || !Array.isArray(routeList) || routeList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const route = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
|
||||
|
||||
for (const item of routeList) {
|
||||
if (!item || typeof item !== 'string') continue;
|
||||
const entry = item.trim();
|
||||
if (!entry) continue;
|
||||
|
||||
// 1) 通配符 /pages_tool/* 或 PAGE_ORDER_*
|
||||
if (entry.endsWith('/*')) {
|
||||
const prefix = entry.slice(0, -1);
|
||||
if (route.startsWith(prefix)) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.endsWith('_*') && pageKeyMap) {
|
||||
const keyPrefix = entry.slice(0, -1);
|
||||
const matchedPaths = [];
|
||||
for (const k of Object.keys(pageKeyMap)) {
|
||||
if (k.startsWith(keyPrefix) && pageKeyMap[k]) {
|
||||
matchedPaths.push(pageKeyMap[k]);
|
||||
}
|
||||
}
|
||||
for (const p of matchedPaths) {
|
||||
const pn = p.startsWith('/') ? p : '/' + p;
|
||||
const dirPrefix = pn.substring(0, pn.lastIndexOf('/') + 1);
|
||||
if (route.startsWith(dirPrefix)) return true;
|
||||
if (route === pn) return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) PAGE_KEY 名称
|
||||
if (pageKeyMap && pageKeyMap[entry]) {
|
||||
let mapped = pageKeyMap[entry];
|
||||
mapped = mapped.startsWith('/') ? mapped : '/' + mapped;
|
||||
if (route === mapped) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) 实际路径
|
||||
if (entry.startsWith('/')) {
|
||||
if (route === entry) return true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// resolveLinkUrl
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 解析 link 对象得到最终可跳转的 URL
|
||||
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
|
||||
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加
|
||||
* 优先级: page_params > wap_url query (新机制优先)
|
||||
*
|
||||
* @param {Object} link
|
||||
* @param {Object} [opts]
|
||||
* @param {Object} [opts.pageKeyMap={}] PAGE_KEY_MAP
|
||||
* @param {Function} [opts.parsePageParams] page_params 解析函数(默认用 util-navigate 的)
|
||||
* @param {Function} [opts.resolvePageUrlFn] page_key → URL 函数(默认用本文件内)
|
||||
* @param {Function} [opts.decodeEntity] HTML 实体解码函数
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveLinkUrl(link, opts = {}) {
|
||||
const {
|
||||
pageKeyMap = {},
|
||||
parsePageParams = null,
|
||||
resolvePageUrlFn = null,
|
||||
decodeEntity = null,
|
||||
} = opts;
|
||||
|
||||
if (!link) return null;
|
||||
|
||||
// 0. 把 wap_url 中的 query 合并到 page_params
|
||||
if (link.wap_url) {
|
||||
const _decode = decodeEntity || ((s) => s);
|
||||
const decodedWap = _decode(link.wap_url);
|
||||
const queryIdx = decodedWap.indexOf('?');
|
||||
if (queryIdx !== -1) {
|
||||
const path = decodedWap.substring(0, queryIdx);
|
||||
const queryStr = decodedWap.substring(queryIdx + 1);
|
||||
const urlParams = {};
|
||||
queryStr.split('&').forEach((kv) => {
|
||||
if (!kv) return;
|
||||
const eqIdx = kv.indexOf('=');
|
||||
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
|
||||
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
|
||||
if (k) urlParams[k] = v;
|
||||
});
|
||||
// 解析 page_params (可能是字符串或对象) — 走统一 helper
|
||||
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
|
||||
const pageParams = _parse(link.page_params);
|
||||
// 合并: page_params 覆盖 urlParams (新机制优先)
|
||||
link.page_params = Object.assign({}, urlParams, pageParams);
|
||||
// 把 link.wap_url 改为不含 query 的 path
|
||||
link.wap_url = path;
|
||||
}
|
||||
}
|
||||
// 1. 优先使用 page_key(新机制)
|
||||
if (link.page_key) {
|
||||
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
|
||||
const _resolvePageUrl = resolvePageUrlFn || ((key, params) => resolvePageUrl(key, params, pageKeyMap));
|
||||
const params = _parse(link.page_params);
|
||||
const url = _resolvePageUrl(link.page_key, params);
|
||||
if (url) return url;
|
||||
}
|
||||
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
|
||||
if (link.wap_url) {
|
||||
return link.wap_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// TabBar 列表与适配
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 当前最新的 tabBar.list
|
||||
* 注意: 该函数需要业务方传入 4 个常量(INDEX/CATEGORY/CONTACT/MEMBER)
|
||||
* 通常由 util.js 在 import 后调用 _setSystemTabBarList 注入
|
||||
*/
|
||||
export function makeSystemTabBarList(indexUrl, categoryUrl, contactUrl, memberUrl) {
|
||||
return [indexUrl, categoryUrl, contactUrl, memberUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* 适配子包路径
|
||||
* 把老路径 /pages/xxx/ 映射到新路径 /pages_xxx/
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export function adaptSubpackageUrl(url) {
|
||||
if (!url) return url;
|
||||
// 定义前缀映射表(key=旧前缀, value=新分包前缀)
|
||||
const urlPrefixMap = {
|
||||
'/pages/promotion/': '/pages_promotion/',
|
||||
'/pages/order/': '/pages_order/',
|
||||
'/pages/goods/': '/pages_goods/',
|
||||
'/pages/member/': '/pages_tool/member/',
|
||||
'/pages/login/': '/pages_tool/login/',
|
||||
'/pages/agreement/': '/pages_tool/agreement/',
|
||||
'/pages/article/': '/pages_tool/article/',
|
||||
'/pages/cases/': '/pages_tool/cases/',
|
||||
'/pages/contact/': '/pages_tool/contact/',
|
||||
'/pages/files/': '/pages_tool/files/',
|
||||
'/pages/form/': '/pages_tool/form/',
|
||||
'/pages/help/': '/pages_tool/help/',
|
||||
'/pages/notice/': '/pages_tool/notice/',
|
||||
'/pages/pay/': '/pages_tool/pay/',
|
||||
'/pages/recharge/': '/pages_tool/recharge/',
|
||||
'/pages/seal/': '/pages_tool/seal/',
|
||||
'/pages/storeclose/': '/pages_tool/storeclose/',
|
||||
'/pages/vr/': '/pages_tool/vr/',
|
||||
'/pages/weapp/': '/pages_tool/weapp/',
|
||||
'/pages/webview/': '/pages_tool/webview/',
|
||||
};
|
||||
|
||||
const prefixes = Object.keys(urlPrefixMap);
|
||||
if (prefixes.length === 0) return url;
|
||||
|
||||
const regex = new RegExp(
|
||||
'^(' + prefixes.map((key) =>
|
||||
key.replace(/\//g, '\\/').replace(/\*/g, '.*'),
|
||||
).join('|') + ')',
|
||||
);
|
||||
|
||||
function replacePrefix(str) {
|
||||
return str.replace(regex, (match) => {
|
||||
const newPrefix = urlPrefixMap[match];
|
||||
return newPrefix || match;
|
||||
});
|
||||
}
|
||||
|
||||
// 兜底: 把 3 个及以上的连续 `/` 收敛为 2 个, 只动 path 部分
|
||||
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
|
||||
return replacePrefix(collapsed);
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// TabBar 命中判断(私有辅助)
|
||||
// =============================================================
|
||||
|
||||
/**
|
||||
* 归一化任意路径: 确保以 '/' 开头, 去除 query, 适配子包
|
||||
* (私有, 仅本模块内部使用)
|
||||
*/
|
||||
function _normalizePath(path) {
|
||||
if (!path) return '';
|
||||
const normalized = path.startsWith('/') ? path : '/' + path;
|
||||
return adaptSubpackageUrl(normalized).split('?')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从归一化路径提取分包名(parts[1])
|
||||
* (私有, 仅本模块内部使用)
|
||||
*/
|
||||
function _getPackageName(normalizedPath) {
|
||||
const parts = normalizedPath.split('/');
|
||||
return parts[1] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析"当前分包的入口 tab URL"
|
||||
*
|
||||
* 把 diy-bottom-nav.vue#updateCurrentRoute 里的查找逻辑抽成纯函数,
|
||||
* 方便单元测试覆盖所有跳转场景, 同时让组件代码变薄.
|
||||
*
|
||||
* 查找优先级 (2026-07-16 重构, 对齐 iOS HIG):
|
||||
* 1) 当前页面精确命中某个 tab 根页面 → 它就是入口 (tab→tab 跳转)
|
||||
* 2) 同包内导航 (上一包 == 当前包) → 用当前包的第一个 tab
|
||||
* 例: /pages_goods/list → /pages_goods/detail → CATEGORY 高亮
|
||||
* 3) 跨包导航 (上一包 != 当前包) → 历史栈倒序查找最近一次访问的 TabBar 页
|
||||
* 例: home → diy → HOME 高亮 (从哪个 tab 出发, 非 tab 页就归属哪个 tab)
|
||||
* 4) 跨包但历史栈中无 tab → 退到当前包第一个 tab
|
||||
* 5) 深链无历史 → 返回 null (iOS HIG: 无来源时不点亮)
|
||||
*
|
||||
* 与 checkTabBarActive 的关系:
|
||||
* - resolveEntryTab 写入 packageEntryTab[currentPackage]
|
||||
* - checkTabBarActive 通过 _findMatchingTabUrl 读取
|
||||
* - 两者都不会做"无历史时的同分包兜底", 保证 iOS HIG 行为
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.currentPageRoute 当前页面路由 (已 _normalizeRoute, 不含 query)
|
||||
* @param {Object} opts.tabBarList tabBar 配置对象 ($store.state.tabBarList)
|
||||
* @param {string[]} [opts.historyRoutes=[]] 历史栈路由列表 (不含 query, 不含当前页;
|
||||
* 已 _normalizeRoute 但未 adaptSubpackageUrl, 函数内部会适配)
|
||||
* @param {Set<string>} [opts.noTabPages] NO_TAB_PAGES 集合 (含 login/pay/AI 等全屏流程页)
|
||||
* 这些页视为"modal", 不参与包归属判定
|
||||
* @returns {string|null} 入口 tab URL (已适配子包路径、不含 query) 或 null
|
||||
*/
|
||||
export function resolveEntryTab(opts = {}) {
|
||||
const {
|
||||
currentPageRoute = '',
|
||||
tabBarList = null,
|
||||
historyRoutes = [],
|
||||
noTabPages = null,
|
||||
} = opts;
|
||||
|
||||
if (!currentPageRoute || !tabBarList || !Array.isArray(tabBarList.list)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabUrls = tabBarList.list
|
||||
.filter((item) => item && item.link && item.link.wap_url)
|
||||
.map((item) => adaptSubpackageUrl(item.link.wap_url).split('?')[0]);
|
||||
|
||||
if (tabUrls.length === 0) return null;
|
||||
|
||||
const normalizedCurrent = currentPageRoute.split('?')[0];
|
||||
const currentPackageName = _getPackageName(normalizedCurrent);
|
||||
|
||||
// 1) 当前页面精确命中某个 tab → 它就是入口
|
||||
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// 工具: 判定一个路由是否"参与归属判定"
|
||||
// 跳过 null/空 + NO_TAB_PAGES (login/pay/AI 等 modal 页, 不算中间步骤)
|
||||
const isSkippable = (route) => {
|
||||
if (!route) return true;
|
||||
if (noTabPages && typeof noTabPages.has === 'function') {
|
||||
const normalized = adaptSubpackageUrl(route).split('?')[0];
|
||||
if (noTabPages.has(normalized)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 找出"最近的上一页"属于哪个包 (跳过 null/空 + NO_TAB_PAGES)
|
||||
let lastPackageName = '';
|
||||
let hasPrevPage = false;
|
||||
if (Array.isArray(historyRoutes) && historyRoutes.length > 0) {
|
||||
for (let i = historyRoutes.length - 1; i >= 0; i--) {
|
||||
const route = historyRoutes[i];
|
||||
if (isSkippable(route)) continue;
|
||||
lastPackageName = _getPackageName(adaptSubpackageUrl(route).split('?')[0]);
|
||||
hasPrevPage = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isSamePackage = hasPrevPage && lastPackageName === currentPackageName;
|
||||
const isCrossPackage = hasPrevPage && lastPackageName !== currentPackageName;
|
||||
|
||||
// 2) 同包内导航: 用当前包的第一个 tab
|
||||
// 业务约定: 包内"主入口" = tabBarList 中该包出现的第一个 tab
|
||||
// 例: pages_goods 有 [CATEGORY, CART] 两个 tab, 用户在 goods 频道内任何页面都归 CATEGORY
|
||||
// 理由: CATEGORY 是商品频道的入口, 比 CART (特定子场景) 更"通用", 视觉一致
|
||||
// 注意: 业务侧 tab 配置顺序很关键, 若有 pages_goods 出现 3+ tab, 主入口必须是业务上正确的那个
|
||||
if (isSamePackage) {
|
||||
const samePackage = tabUrls.find((url) => _getPackageName(url) === currentPackageName);
|
||||
if (samePackage) return samePackage;
|
||||
}
|
||||
|
||||
// 3) 跨包导航: 历史栈倒序查找最近一次访问的 TabBar 页 → 来源 tab
|
||||
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
|
||||
// 注意: 跳过 NO_TAB_PAGES, 因为 login/pay 等是 modal, 不是真实的导航步骤
|
||||
if (isCrossPackage) {
|
||||
for (let i = historyRoutes.length - 1; i >= 0; i--) {
|
||||
const route = historyRoutes[i];
|
||||
if (isSkippable(route)) continue;
|
||||
const normalized = adaptSubpackageUrl(route).split('?')[0];
|
||||
const exactTab = tabUrls.find((url) => url === normalized);
|
||||
if (exactTab) return exactTab;
|
||||
}
|
||||
// 4) 跨包但历史栈中无 tab → 退到当前包第一个 tab
|
||||
const samePackage = tabUrls.find((url) => _getPackageName(url) === currentPackageName);
|
||||
if (samePackage) return samePackage;
|
||||
}
|
||||
|
||||
// 5) 深链无历史 / 跳过 NO_TAB_PAGES 后没找到 → 返回 null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给定当前页面路径 + tab 配置, 返回当前页面所属的 tab URL
|
||||
* 优先级: 精确匹配 > packageEntryTab
|
||||
*
|
||||
* 注意 (2026-07-16): 旧版本在 packageEntryTab 没命中时会回退到"同分包第一个 tab",
|
||||
* 这导致深链冷启动时 /pages_tool/* 页被错误标成"我的"。
|
||||
* 现在改为"找不到就返回 null", 后续 checkTabBarActive 也不会再兜底,
|
||||
* 行为对齐 iOS HIG (深链无历史时, TabBar 不点亮)。
|
||||
*
|
||||
* (私有)
|
||||
*/
|
||||
function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
|
||||
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return null;
|
||||
|
||||
const tabUrls = tabBarList.list
|
||||
.filter((item) => item.link && item.link.wap_url)
|
||||
.map((item) => _normalizePath(item.link.wap_url));
|
||||
|
||||
const normalizedCurrent = _normalizePath(currentPagePath);
|
||||
|
||||
// 1) 精确匹配 → 当前页面就是 tab 页面
|
||||
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// 2) 通过 packageEntryTab(由 updateCurrentRoute/redirectTo 写入)
|
||||
const packageName = _getPackageName(normalizedCurrent);
|
||||
if (packageEntryTab && typeof packageEntryTab === 'object' && packageEntryTab[packageName]) {
|
||||
const entryTab = packageEntryTab[packageName];
|
||||
const normalizedEntry = _normalizePath(entryTab);
|
||||
if (tabUrls.includes(normalizedEntry)) return normalizedEntry;
|
||||
}
|
||||
|
||||
// 3) 不再做"同分包兜底" — 找不到就返回 null
|
||||
// 深链冷启动场景下无历史可参考, iOS HIG 推荐 TabBar 不点亮
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通配符路径匹配 — 支持 '/pages_tool/member/*' 形式
|
||||
*
|
||||
* 规则:
|
||||
* - pattern 必须以 '/*' 结尾
|
||||
* - '*' 匹配"前缀之后的至少一个字符段"
|
||||
* - 例: '/pages_tool/member/*' 匹配 '/pages_tool/member/info' 但不匹配 '/pages_tool/member'
|
||||
*
|
||||
* @param {string} path 当前路径 (已 _normalizePath)
|
||||
* @param {string} pattern 通配符 pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _matchWildcardPath(path, pattern) {
|
||||
if (!path || !pattern) return false;
|
||||
if (!pattern.endsWith('/*')) return false;
|
||||
const prefix = pattern.slice(0, -2); // 去掉 '/*'
|
||||
// 必须有至少一个字符在 '/*' 之后, 即 path 比 prefix 长, 且以 prefix + '/' 开头
|
||||
return path.length > prefix.length + 1 && path.startsWith(prefix + '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 PAGE_TAB_MAP (精确) + PAGE_TAB_WILDCARDS (通配) 中查找当前页面对应的 tab
|
||||
*
|
||||
* 优先级: 精确匹配 > 通配符匹配
|
||||
* 返回值可以是:
|
||||
* - tab URL 字符串 → 命中, 高亮该 tab
|
||||
* - null → 显式声明"无 tab", 一定不高亮
|
||||
* - undefined → 未声明, 调用方走兜底逻辑 (history-walk / 无 tab)
|
||||
*
|
||||
* @param {string} currentPath 当前页面路径 (已 _normalizePath)
|
||||
* @param {Object} pageTabMap 精确绑定表 (PAGE_TAB_MAP)
|
||||
* @param {Array<{pattern: string, tab: string}>} pageTabWildcards 通配符列表 (PAGE_TAB_WILDCARDS)
|
||||
* @returns {string|null|undefined}
|
||||
*/
|
||||
export function lookupPageTabMap(currentPath, pageTabMap, pageTabWildcards) {
|
||||
if (!currentPath) return undefined;
|
||||
|
||||
// 1) 精确匹配
|
||||
if (pageTabMap && Object.prototype.hasOwnProperty.call(pageTabMap, currentPath)) {
|
||||
return pageTabMap[currentPath];
|
||||
}
|
||||
|
||||
// 2) 通配符匹配
|
||||
if (Array.isArray(pageTabWildcards)) {
|
||||
for (const entry of pageTabWildcards) {
|
||||
if (entry && entry.pattern && _matchWildcardPath(currentPath, entry.pattern)) {
|
||||
return entry.tab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
|
||||
*
|
||||
* @param {string} pagePath 当前页面路径(可以带/不带前导斜杠、可以带 query)
|
||||
* @param {Object|null} tabBarList tabBar 配置对象($store.state.tabBarList)
|
||||
* @returns {boolean} 如果是 TabBar 根页面返回 true
|
||||
*/
|
||||
export function isTabBarRootPage(pagePath, tabBarList) {
|
||||
if (!pagePath) return false;
|
||||
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return false;
|
||||
const normalizedCurrent = _normalizePath(pagePath);
|
||||
if (!normalizedCurrent) return false;
|
||||
return tabBarList.list.some((item) => {
|
||||
if (!item || !item.link || !item.link.wap_url) return false;
|
||||
const normalizedTab = _normalizePath(item.link.wap_url);
|
||||
return normalizedTab === normalizedCurrent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断**当前**页面是否是 TabBar 根页面
|
||||
* 封装了 getCurrentPages() + isTabBarRootPage 的组合, 供业务组件在弹窗/操作前判断
|
||||
*
|
||||
* 调用方需要注入 getCurrentPages 和 store:
|
||||
* isCurrentPageTabBarRoot({
|
||||
* getCurrentPages: typeof getCurrentPages === 'function' ? getCurrentPages : null,
|
||||
* store,
|
||||
* tabBarList: null, // 不传则从 store.state.tabBarList 取
|
||||
* })
|
||||
*
|
||||
* @param {Object} [opts={}]
|
||||
* @param {Function} [opts.getCurrentPages]
|
||||
* @param {Object} [opts.store]
|
||||
* @param {Object} [opts.tabBarList]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCurrentPageTabBarRoot(opts = {}) {
|
||||
try {
|
||||
const gcp = opts.getCurrentPages || (typeof getCurrentPages === 'function' ? getCurrentPages : null);
|
||||
if (!gcp) return false;
|
||||
const pages = gcp();
|
||||
if (!pages || !pages.length) return false;
|
||||
const currentRoute = '/' + String(pages[pages.length - 1].route || '').replace(/^\//, '');
|
||||
if (!currentRoute) return false;
|
||||
const tabBarList = opts.tabBarList
|
||||
|| (opts.store && opts.store.state ? opts.store.state.tabBarList : null);
|
||||
return isTabBarRootPage(currentRoute, tabBarList);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 tabBar 是否激活
|
||||
*
|
||||
* @param {string} linkUrl 链接路径 /pages/goods/category
|
||||
* @param {string} currentPageRoute 当前页面路径 /pages_goods/category
|
||||
* @param {Object} tabBarList tabBar 配置对象, 用于动态判断
|
||||
* @param {Object} packageEntryTab 追踪每个分包的入口 Tab { 'pages_goods': '/pages_goods/category' }
|
||||
* @param {Object} [currentPageQuery={}] 当前页面 query 参数, 用于读取 from_tab 来源记忆
|
||||
* @param {Object} [opts={}]
|
||||
* @param {Set} [opts.noTabPages] 独立流程页集合(NO_TAB_PAGES)
|
||||
* @param {Object} [opts.pageTabMap] 页面级 Tab 精确绑定表(PAGE_TAB_MAP)
|
||||
* @param {Array<{pattern: string, tab: string}>} [opts.pageTabWildcards=[]] 页面级 Tab 通配符表(PAGE_TAB_WILDCARDS)
|
||||
* @returns {boolean} 是否激活
|
||||
*/
|
||||
export function checkTabBarActive(
|
||||
linkUrl,
|
||||
currentPageRoute,
|
||||
tabBarList,
|
||||
packageEntryTab,
|
||||
currentPageQuery = {},
|
||||
opts = {},
|
||||
) {
|
||||
const { noTabPages = null, pageTabMap = {}, pageTabWildcards = [] } = opts;
|
||||
|
||||
// __TABBAR_DEBUG__: 开启后打印每一步决策, 用于排查"为什么我的 tab 不亮"
|
||||
// 开启方式 (任选一种):
|
||||
// - 全局: window.__TABBAR_DEBUG__ = true
|
||||
// - 本次: opts.debug = true
|
||||
const DEBUG = (typeof globalThis !== 'undefined' && globalThis.__TABBAR_DEBUG__)
|
||||
|| opts.debug === true;
|
||||
const _log = DEBUG ? (...a) => console.log('[TABBAR]', ...a) : () => {};
|
||||
_log('checkTabBarActive called', { linkUrl, currentPageRoute, hasTabBarList: !!tabBarList });
|
||||
|
||||
// 1) 归一化 linkUrl: 统一前导 '/' + 子包路径适配 + 去除 query
|
||||
const normalizedLink = _normalizePath(linkUrl);
|
||||
const normalizedCurrent = _normalizePath(currentPageRoute);
|
||||
|
||||
if (!normalizedLink) {
|
||||
_log('step 1: no linkUrl → false');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2) 精确匹配: 当前页面就是 linkUrl 指向的页面
|
||||
if (normalizedLink === normalizedCurrent) {
|
||||
_log('step 2: exact match → true', { link: normalizedLink });
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) 独立流程页: 不点亮任何 Tab
|
||||
if (noTabPages && noTabPages.has && noTabPages.has(normalizedCurrent)) {
|
||||
_log('step 3: NO_TAB_PAGES hit → false', { current: normalizedCurrent });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4) 若传入 tabBarList, 使用动态判断逻辑
|
||||
if (tabBarList && tabBarList.list && Array.isArray(tabBarList.list)) {
|
||||
const tabUrls = tabBarList.list
|
||||
.filter((item) => item.link && item.link.wap_url)
|
||||
.map((item) => _normalizePath(item.link.wap_url));
|
||||
|
||||
// 检查当前 linkUrl 是否是合法的 tab URL
|
||||
if (!tabUrls.includes(normalizedLink)) {
|
||||
_log('step 4: link not in tabUrls → false', { link: normalizedLink });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5) 页面级 Tab 绑定 (精确 + 通配符)
|
||||
// 返回值可能是: tab URL | null (显式无 tab) | undefined (未声明)
|
||||
const mappedTabUrl = lookupPageTabMap(normalizedCurrent, pageTabMap, pageTabWildcards);
|
||||
if (mappedTabUrl !== undefined) {
|
||||
_log('step 5: pageTabMap hit', { current: normalizedCurrent, mapped: mappedTabUrl });
|
||||
// null = 显式声明"无 tab", 字符串 = 该 tab 高亮
|
||||
return mappedTabUrl !== null && normalizedLink === _normalizePath(mappedTabUrl);
|
||||
}
|
||||
_log('step 5: pageTabMap miss → 进入 step 6/7');
|
||||
|
||||
// 6) 来源记忆兜底: 从哪个 Tab 跳转过来, 就保持那个 Tab 高亮
|
||||
const fromTab = currentPageQuery.from_tab;
|
||||
if (fromTab) {
|
||||
const normalizedFromTab = _normalizePath(fromTab);
|
||||
if (tabUrls.includes(normalizedFromTab)) {
|
||||
const hit = normalizedLink === normalizedFromTab;
|
||||
_log('step 6: from_tab query', { fromTab, hit });
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
_log('step 6: no from_tab');
|
||||
|
||||
// 7) 分包入口追踪: 通过 updateCurrentRoute/resolveEntryTab 写入的入口 tab
|
||||
// 注意: _findMatchingTabUrl 不会做"同分包兜底",找不到就返回 null
|
||||
const matchingTabUrl = _findMatchingTabUrl(currentPageRoute, tabBarList, packageEntryTab);
|
||||
if (matchingTabUrl) {
|
||||
const hit = normalizedLink === matchingTabUrl;
|
||||
_log('step 7: packageEntryTab', { matchingTabUrl, hit });
|
||||
return hit;
|
||||
}
|
||||
_log('step 7: packageEntryTab miss → step 8');
|
||||
|
||||
// 8) 不再做"同分包兜底" — 找不到任何来源 tab 时不高亮
|
||||
// 深链冷启动 / 扫码进非 tab 页: TabBar 全不亮, 对齐 iOS HIG
|
||||
_log('step 8: no source → false (iOS HIG)');
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
1474
common/js/util.js
1474
common/js/util.js
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,142 @@
|
||||
/**
|
||||
* 微信jssdk调用
|
||||
*/
|
||||
let Weixin = function () {
|
||||
var wx = require('jweixin-module');
|
||||
|
||||
this.weixin = wx;
|
||||
|
||||
this.init = function (params) {
|
||||
wx.config({
|
||||
debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
|
||||
appId: params.appId, // 必填,公众号的唯一标识
|
||||
timestamp: params.timestamp, // 必填,生成签名的时间戳
|
||||
nonceStr: params.nonceStr, // 必填,生成签名的随机串
|
||||
signature: params.signature, // 必填,签名
|
||||
jsApiList: ['chooseWXPay', 'openAddress', 'updateAppMessageShareData',
|
||||
'updateTimelineShareData', 'scanQRCode', 'hideMenuItems'
|
||||
] // 必填,需要使用的JS接口列表
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起支付
|
||||
* @param jsApiParame
|
||||
* @param callback
|
||||
* @param cancel
|
||||
*/
|
||||
this.pay = function (jsApiParame, callback, cancel) {
|
||||
wx.ready(function () {
|
||||
wx.chooseWXPay({
|
||||
timestamp: jsApiParame
|
||||
.timestamp, // 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
|
||||
nonceStr: jsApiParame.nonceStr, // 支付签名随机串,不长于 32 位
|
||||
package: jsApiParame.package, // 统一支付接口返回的prepay_id参数值,提交格式如:prepay_id=\*\*\*)
|
||||
signType: jsApiParame.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
|
||||
paySign: jsApiParame.paySign, // 支付签名
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
},
|
||||
cancel: function (res) {
|
||||
typeof cancel == 'function' && cancel(res);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收货地址
|
||||
* @param {Object} callback
|
||||
*/
|
||||
this.openAddress = function (callback) {
|
||||
wx.ready(function () {
|
||||
wx.openAddress({
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
},
|
||||
fail: (res) => {
|
||||
alert(JSON.stringify(res))
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享给好友
|
||||
* @param {Object} params
|
||||
* @param {Object} callback
|
||||
*/
|
||||
this.setShareData = function (params, callback) {
|
||||
|
||||
wx.ready(function () {
|
||||
// 自定义“分享给朋友”及“分享到QQ”按钮的分享内容
|
||||
wx.updateAppMessageShareData({
|
||||
title: params.title || '', // 分享标题
|
||||
desc: params.desc || '', // 分享描述
|
||||
link: params.link || '', // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
|
||||
imgUrl: params.imgUrl || '', // 分享图标
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
},
|
||||
fail: function (err) {
|
||||
}
|
||||
})
|
||||
|
||||
// 自定义“分享到朋友圈”及“分享到QQ空间”按钮的分享内容
|
||||
wx.updateTimelineShareData({
|
||||
title: params.title || '', // 分享标题
|
||||
link: params.link || '', // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
|
||||
imgUrl: params.imgUrl || '', // 分享图标
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫一扫
|
||||
* @param {Object} callback
|
||||
*/
|
||||
this.scanQRCode = function (callback) {
|
||||
wx.ready(function () {
|
||||
wx.scanQRCode({
|
||||
needResult: 1,
|
||||
scanType: ["qrCode"],
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
Weixin
|
||||
/**
|
||||
* 微信jssdk调用
|
||||
*/
|
||||
let Weixin = function () {
|
||||
var wx = require('jweixin-module');
|
||||
|
||||
this.weixin = wx;
|
||||
|
||||
this.init = function (params) {
|
||||
wx.config({
|
||||
debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
|
||||
appId: params.appId, // 必填,公众号的唯一标识
|
||||
timestamp: params.timestamp, // 必填,生成签名的时间戳
|
||||
nonceStr: params.nonceStr, // 必填,生成签名的随机串
|
||||
signature: params.signature, // 必填,签名
|
||||
jsApiList: ['chooseWXPay', 'openAddress', 'updateAppMessageShareData',
|
||||
'updateTimelineShareData', 'scanQRCode', 'hideMenuItems'
|
||||
] // 必填,需要使用的JS接口列表
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起支付
|
||||
* @param jsApiParame
|
||||
* @param callback
|
||||
* @param cancel
|
||||
*/
|
||||
this.pay = function (jsApiParame, callback, cancel) {
|
||||
wx.ready(function () {
|
||||
wx.chooseWXPay({
|
||||
timestamp: jsApiParame
|
||||
.timestamp, // 支付签名时间戳,注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
|
||||
nonceStr: jsApiParame.nonceStr, // 支付签名随机串,不长于 32 位
|
||||
package: jsApiParame.package, // 统一支付接口返回的prepay_id参数值,提交格式如:prepay_id=\*\*\*)
|
||||
signType: jsApiParame.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
|
||||
paySign: jsApiParame.paySign, // 支付签名
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
},
|
||||
cancel: function (res) {
|
||||
typeof cancel == 'function' && cancel(res);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收货地址
|
||||
* @param {Object} callback
|
||||
*/
|
||||
this.openAddress = function (callback) {
|
||||
wx.ready(function () {
|
||||
wx.openAddress({
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
},
|
||||
fail: (res) => {
|
||||
console.log('获取收货地址 fail',res);
|
||||
alert(JSON.stringify(res))
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享给好友
|
||||
* @param {Object} params
|
||||
* @param {Object} callback
|
||||
*/
|
||||
this.setShareData = function (params, callback) {
|
||||
|
||||
wx.ready(function () {
|
||||
// 自定义“分享给朋友”及“分享到QQ”按钮的分享内容
|
||||
wx.updateAppMessageShareData({
|
||||
title: params.title || '', // 分享标题
|
||||
desc: params.desc || '', // 分享描述
|
||||
link: params.link || '', // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
|
||||
imgUrl: params.imgUrl || '', // 分享图标
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
},
|
||||
fail: function (err) {
|
||||
}
|
||||
})
|
||||
|
||||
// 自定义“分享到朋友圈”及“分享到QQ空间”按钮的分享内容
|
||||
wx.updateTimelineShareData({
|
||||
title: params.title || '', // 分享标题
|
||||
link: params.link || '', // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
|
||||
imgUrl: params.imgUrl || '', // 分享图标
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫一扫
|
||||
* @param {Object} callback
|
||||
*/
|
||||
this.scanQRCode = function (callback) {
|
||||
wx.ready(function () {
|
||||
wx.scanQRCode({
|
||||
needResult: 1,
|
||||
scanType: ["qrCode"],
|
||||
success: function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// 提现
|
||||
this.withdrawWechat = function (data,callback) {
|
||||
wx.ready(function () {
|
||||
wx.checkJsApi({
|
||||
jsApiList: ['requestMerchantTransfer'],
|
||||
success: function (res) {
|
||||
if (res.checkResult['requestMerchantTransfer']) {
|
||||
WeixinJSBridge.invoke('requestMerchantTransfer', {
|
||||
mchId: data.mch_id,
|
||||
appId: data.wechat_appid,
|
||||
package: data.package_info,
|
||||
},
|
||||
function (res) {
|
||||
typeof callback == 'function' && callback(res);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
alert('你的微信版本过低,请更新至最新版本。');
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Weixin
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* 企业微信JS-SDK调用
|
||||
*/
|
||||
import Lang from './lang.js';
|
||||
|
||||
let WxWork = function () {
|
||||
// 企业微信JS-SDK
|
||||
this.wxwork = null;
|
||||
@@ -25,7 +23,7 @@ let WxWork = function () {
|
||||
// H5环境下的企业微信
|
||||
this.wxwork = WWOpenData;
|
||||
} else {
|
||||
console.error(Lang.lang('wxworkJssdk.notLoaded'));
|
||||
console.error('企业微信JS-SDK未加载');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -58,8 +56,8 @@ let WxWork = function () {
|
||||
*/
|
||||
this.addContact = function (params, success, fail) {
|
||||
if (!this.wxwork) {
|
||||
console.error(Lang.lang('wxworkJssdk.notInitialized'));
|
||||
if (fail) fail(Lang.lang('wxworkJssdk.notInitialized'));
|
||||
console.error('企业微信JS-SDK未初始化');
|
||||
if (fail) fail('企业微信JS-SDK未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +69,7 @@ let WxWork = function () {
|
||||
if (res.err_msg === 'openUserProfile:ok') {
|
||||
if (success) success(res);
|
||||
} else {
|
||||
console.error(Lang.lang('wxworkJssdk.openUserProfileFailed'), res);
|
||||
console.error('打开用户资料失败:', res);
|
||||
if (fail) fail(res.err_msg);
|
||||
}
|
||||
});
|
||||
@@ -89,8 +87,8 @@ let WxWork = function () {
|
||||
*/
|
||||
this.openCustomerService = function (params, success, fail) {
|
||||
if (!this.wxwork) {
|
||||
console.error(Lang.lang('wxworkJssdk.notInitialized'));
|
||||
if (fail) fail(Lang.lang('wxworkJssdk.notInitialized'));
|
||||
console.error('企业微信JS-SDK未初始化');
|
||||
if (fail) fail('企业微信JS-SDK未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 延迟初始化 i18n mixin
|
||||
*
|
||||
* 问题背景:
|
||||
* 微信小程序/鸿蒙元服务环境下, main.js 同步 $mount() 会导致组件 data() 在
|
||||
* Lang.boot() 完成前求值. 此时 $lang() 在 remote-only 模式下返回空串,
|
||||
* 把空串缓存到 data 字段后, 后续 boot 完成的 $forceUpdate 也无法让它重算.
|
||||
*
|
||||
* 解决思路:
|
||||
* data() 中不能调 $lang(), 否则空串被永久缓存.
|
||||
* 模板只读 this.i18n.xxx(始终是字符串, 初值为空).
|
||||
* 需要显示前, 显式调一次 this.prepareI18n() — 此时 boot 必然已完成.
|
||||
*
|
||||
* 用法:
|
||||
*
|
||||
* // 1) 在 data() 中:
|
||||
* data() {
|
||||
* return {
|
||||
* i18n: {
|
||||
* title: '',
|
||||
* descPrefix: '',
|
||||
* privacyContractName: '',
|
||||
* },
|
||||
* // 准备文案时需要的动态上下文(可选)
|
||||
* platformContractName: '',
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* // 2) 在 methods 中实现 i18nPrepareFn, 返回一个新对象用于填充 this.i18n
|
||||
* // 其中 key 用反引号 `` `your.module.title` `` 这类命名代表"业务模块的真实 key",
|
||||
* // 请用业务组件实际使用的 key 替换(例如 'components.privacyPopup.title')
|
||||
* methods: {
|
||||
* i18nPrepareFn() {
|
||||
* return {
|
||||
* title: this.$lang(`your.module.title`),
|
||||
* descPrefix: this.$lang('components.privacyPopup.descPrefix', { appName: '本应用' }),
|
||||
* privacyContractName: this.platformContractName || this.$lang(`your.module.defaultContract`),
|
||||
* };
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* // 3) 在需要显示前调 this.prepareI18n()(来自 mixin)
|
||||
* // 此时 Lang.boot() 必然已完成, $lang() 返回真实翻译
|
||||
* popUp() {
|
||||
* this.prepareI18n();
|
||||
* this.showPop = true;
|
||||
* }
|
||||
*
|
||||
* 模板中只读 i18n.xxx:
|
||||
* ```html
|
||||
* <view>{{ i18n.title }}</view>
|
||||
* <view>{{ i18n.descPrefix }}</view>
|
||||
* ```
|
||||
*/
|
||||
export default {
|
||||
methods: {
|
||||
/**
|
||||
* 一次性计算所有 i18n 文案并写入 this.i18n
|
||||
* 关键: 调用时机必须是 boot 完成之后(否则会缓存空串)
|
||||
* 模板中读 this.i18n.xxx 永远拿到字符串
|
||||
*
|
||||
* 业务组件需要实现 i18nPrepareFn() 方法,
|
||||
* 返回一个对象, key 与 data().i18n 字段一一对应
|
||||
*/
|
||||
prepareI18n() {
|
||||
if (typeof this.i18nPrepareFn !== 'function') {
|
||||
console.warn('[i18n-lazy] i18nPrepareFn() not implemented; this.i18n will not be filled');
|
||||
return;
|
||||
}
|
||||
this.i18n = this.i18nPrepareFn();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 微信小程序图片长按识别二维码 mixin
|
||||
* 自动为页面中的所有 image 组件添加 show-menu-by-longpress 属性
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
// 需要排除的图片类名(不需要长按识别的)
|
||||
excludeImageClass: ['no-longpress', 'swiper-image', 'banner-image']
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// #ifdef MP-WEIXIN
|
||||
this.$nextTick(() => {
|
||||
this.addLongpressToImages()
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 为页面中的所有 image 添加长按识别支持
|
||||
*/
|
||||
addLongpressToImages() {
|
||||
// 获取当前页面所有 image 组件
|
||||
const images = document.querySelectorAll && document.querySelectorAll('image')
|
||||
if (images) {
|
||||
images.forEach(img => {
|
||||
// 检查是否需要排除
|
||||
const className = img.className || ''
|
||||
const shouldExclude = this.excludeImageClass.some(cls => className.includes(cls))
|
||||
if (!shouldExclude) {
|
||||
img.setAttribute('show-menu-by-longpress', 'true')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// 公共微信视频号样式
|
||||
|
||||
// CSS 变量
|
||||
:root {
|
||||
// 尺寸变量
|
||||
--channel-play-btn-size: 80rpx;
|
||||
--channel-play-btn-small-size: 60rpx;
|
||||
--channel-play-btn-icon-size: 40rpx;
|
||||
--channel-play-btn-icon-small-size: 30rpx;
|
||||
--channel-border-radius: 12rpx;
|
||||
--channel-stats-padding: 10rpx;
|
||||
--channel-info-wrap-padding: 10rpx 0;
|
||||
|
||||
// 字体变量
|
||||
--channel-name-font-size: 28rpx;
|
||||
--video-title-font-size: 28rpx;
|
||||
--video-title-small-font-size: 24rpx;
|
||||
--video-stats-font-size: 24rpx;
|
||||
--channel-stats-font-size: 20rpx;
|
||||
|
||||
// 颜色变量
|
||||
--channel-name-color: #333;
|
||||
--video-title-color: #333;
|
||||
--video-title-small-color: #666;
|
||||
--video-stats-color: #999;
|
||||
--channel-stats-color: #fff;
|
||||
--channel-border-color: #f0f0f0;
|
||||
--channel-play-btn-bg: rgba(0, 0, 0, 0.4);
|
||||
--channel-stats-bg: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||||
|
||||
// 间距变量
|
||||
--channel-name-margin-bottom: 4rpx;
|
||||
--video-title-margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 375px) {
|
||||
:root {
|
||||
--channel-play-btn-size: 70rpx;
|
||||
--channel-play-btn-small-size: 50rpx;
|
||||
--channel-play-btn-icon-size: 35rpx;
|
||||
--channel-play-btn-icon-small-size: 25rpx;
|
||||
--channel-name-font-size: 24rpx;
|
||||
--video-title-font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 750px) {
|
||||
:root {
|
||||
--channel-play-btn-size: 90rpx;
|
||||
--channel-play-btn-small-size: 70rpx;
|
||||
--channel-play-btn-icon-size: 45rpx;
|
||||
--channel-play-btn-icon-small-size: 35rpx;
|
||||
--channel-name-font-size: 32rpx;
|
||||
--video-title-font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// 播放按钮样式
|
||||
.channel-play-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--channel-play-btn-size);
|
||||
height: var(--channel-play-btn-size);
|
||||
background-color: var(--channel-play-btn-bg);
|
||||
border-radius: 50%;
|
||||
|
||||
.play-icon {
|
||||
width: var(--channel-play-btn-icon-size);
|
||||
height: var(--channel-play-btn-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
// 小尺寸播放按钮(用于列表)
|
||||
.channel-play-btn.small {
|
||||
width: var(--channel-play-btn-small-size);
|
||||
height: var(--channel-play-btn-small-size);
|
||||
|
||||
.play-icon {
|
||||
width: var(--channel-play-btn-icon-small-size);
|
||||
height: var(--channel-play-btn-icon-small-size);
|
||||
}
|
||||
}
|
||||
|
||||
// 视频统计信息
|
||||
.channel-stats {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--channel-stats-bg);
|
||||
padding: var(--channel-stats-padding);
|
||||
color: var(--channel-stats-color);
|
||||
font-size: var(--channel-stats-font-size);
|
||||
}
|
||||
|
||||
// 视频信息容器
|
||||
.channel-info-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: var(--channel-info-wrap-padding);
|
||||
|
||||
.channel-name {
|
||||
margin-bottom: var(--channel-name-margin-bottom);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
font-size: var(--channel-name-font-size);
|
||||
font-weight: 500;
|
||||
color: var(--channel-name-color);
|
||||
}
|
||||
|
||||
.video-title {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
font-size: var(--video-title-small-font-size);
|
||||
color: var(--video-title-small-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 视频标题
|
||||
.video-title {
|
||||
font-size: var(--video-title-font-size);
|
||||
font-weight: 500;
|
||||
color: var(--video-title-color);
|
||||
margin-bottom: var(--video-title-margin-bottom);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 视频统计信息(非绝对定位版本)
|
||||
.video-stats {
|
||||
font-size: var(--video-stats-font-size);
|
||||
color: var(--video-stats-color);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,446 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-audio">
|
||||
<view class="fui-audio style1" :style="{ background: value.background }" v-if="value.type == 'style-2'">
|
||||
<view class="content" style="padding-top: 20rpx;">
|
||||
<view class="name" :style="{ color: value.textcolor }">{{ value.text }}</view>
|
||||
<view class="author" :style="{ color: value.subtitlecolor }">{{ value.desc }}----{{ value.id }}</view>
|
||||
</view>
|
||||
<view class="progress">
|
||||
<view class="progressBar" :style="{ width: audiowidth }"></view>
|
||||
</view>
|
||||
<view class="time" :style="{ color: value.timecolor }">
|
||||
{{ audiotime }}
|
||||
</view>
|
||||
<view @tap.stop="play()" class="start" :class="status ? 'iconj icon-07zanting' : 'iconj icon-bofang'"
|
||||
style="padding-top: 18rpx"></view>
|
||||
</view>
|
||||
<view class="fui-audio style3" :style="{ background: value.background }" v-else>
|
||||
<!-- <audio src="/static/audio/bgm.mp3" controls loop></audio> -->
|
||||
<view class="img">
|
||||
<image :src="$util.img(value.imageUrl)" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="content">
|
||||
<view class="name" :style="{ color: value.textcolor }">{{ value.text }}</view>
|
||||
<view class="author" :style="{ color: value.subtitlecolor }">{{ value.desc }}</view>
|
||||
</view>
|
||||
<view class="progress">
|
||||
<view class="progressBar" :style="{ width: audiowidth }"></view>
|
||||
</view>
|
||||
<view class="time" :style="{ color: value.timecolor }">
|
||||
<!-- {{audios[value.id].audiotime}} -->
|
||||
{{ audiotime }}
|
||||
</view>
|
||||
<view @tap.stop="play()" class="start" :class="status ? 'iconj icon-07zanting' : 'iconj icon-bofang'"></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
// 音频
|
||||
export default {
|
||||
name: 'diy-audio',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
audiosObj: [],
|
||||
audios: {},
|
||||
audioContext: null,
|
||||
audiotime: '00:01',
|
||||
audiowidth: 0,
|
||||
status: 0,//1播放0停止
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// console.log(this.value)
|
||||
this.audios[this.value.id] = {
|
||||
audiotime: '00:01',
|
||||
audiowidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
computed: {
|
||||
videoWarpCss: function () {
|
||||
var obj = '';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
play() {
|
||||
|
||||
var t = this.value.id, a = this.audiosObj[t] || !1;
|
||||
var e = {
|
||||
audio: this.$util.img(this.value.audioUrl),
|
||||
}
|
||||
if (!a) {
|
||||
a = uni.createInnerAudioContext("audio_" + t);
|
||||
var i = this.audiosObj;
|
||||
i[t] = a, this.audiosObj = i
|
||||
// uni.setStorageSync('audio_list',audio_list)
|
||||
var audio_list = uni.getStorageSync('audio_list') ? uni.getStorageSync('audio_list') : []
|
||||
if (audio_list.includes(t) == false) {
|
||||
audio_list.push(t)
|
||||
uni.setStorageSync('audio_list', audio_list)
|
||||
}
|
||||
}
|
||||
var n = this;
|
||||
a.onPlay(function () {
|
||||
var e = setInterval(function () {
|
||||
var i = a.currentTime / a.duration * 100 + "%", s = Math.floor(Math.ceil(a.currentTime) / 60), o = (Math.ceil(a.currentTime) % 60 / 100).toFixed(2).slice(-2), r = Math.ceil(a.currentTime);
|
||||
s < 10 && (s = "0" + s);
|
||||
var u = s + ":" + o, c = n.audios;
|
||||
// console.log(i)
|
||||
c[t].audiowidth = i, c[t].Time = e, c[t].audiotime = u, c[t].seconds = r, n.audios = c;
|
||||
}, 1e3);
|
||||
});
|
||||
|
||||
var s = n.$util.img(n.value.audioUrl), o = n.audios[n.value.id].seconds || 0, r = 0, u = 1;
|
||||
0 == u && a.onEnded(function (e) {
|
||||
c[t].status = !1, n.status = !1, c[t].seconds = 0, n.audios = c;
|
||||
});
|
||||
var c = n.audios;
|
||||
c[t] || (c[t] = {}), a.paused && 0 == o ? (a.src = s, a.play(), 1 == u && (a.loop = !0),
|
||||
c[t].status = !0, n.status = !0, n.pauseOther(t)) : a.paused && o > 0 ? (a.play(), 0 == r ? a.seek(o) : a.seek(0),
|
||||
c[t].status = !0, n.status = !0, n.pauseOther(t)) : (a.pause(), c[t].status = !1, n.status = !1), n.audios = c;
|
||||
},
|
||||
pauseOther: function (e) {
|
||||
var t = this;
|
||||
// console.log(this.audiosObj[this.value.id]);
|
||||
var i = this.audiosObj[this.value.id], a = this.value.id
|
||||
// console.log(i)
|
||||
// console.log(a)
|
||||
// if (a != e) {
|
||||
// i.pause();
|
||||
// var n = t.audios;
|
||||
// n[a] && (n[a].status = !1, this.audios=n);
|
||||
// }
|
||||
var audios = document.getElementsByTagName("audio");
|
||||
// 暂停函数
|
||||
function pauseAll() {
|
||||
var self = this;
|
||||
[].forEach.call(audios, function (i) {
|
||||
// 将audios中其他的audio全部暂停
|
||||
i !== self && i.pause();
|
||||
})
|
||||
}
|
||||
// 给play事件绑定暂停函数
|
||||
[].forEach.call(audios, function (i) {
|
||||
i.addEventListener("play", pauseAll.bind(i));
|
||||
})
|
||||
|
||||
|
||||
// var audio_list = uni.getStorageSync('audio_list')
|
||||
// audio_list.forEach(function(value, index) {
|
||||
// if (value != e) {
|
||||
// console.log(e)
|
||||
// uni.createInnerAudioContext("audio_" + value).pause();
|
||||
// }
|
||||
// });
|
||||
|
||||
// this.audiosObj.forEach(function(value, index) {
|
||||
// console.log(value);
|
||||
// });
|
||||
// this.each(this.audiosObj, function(a, i) {
|
||||
// if (a != e) {
|
||||
// i.pause();
|
||||
// var n = t.data.audios;
|
||||
// n[a] && (n[a].status = !1, t.setData({
|
||||
// audios: n
|
||||
// }));
|
||||
// }
|
||||
// });
|
||||
},
|
||||
|
||||
|
||||
play_bak() {
|
||||
|
||||
var t = this.value.id
|
||||
this.audioContext = uni.createInnerAudioContext("audio_" + this.value.id);
|
||||
this.audioContext.src = this.$util.img(this.value.audioUrl);
|
||||
var that = this
|
||||
if (this.status == 1) {
|
||||
this.audioContext.pause();
|
||||
this.status = 0
|
||||
return false
|
||||
}
|
||||
this.audioContext.play();
|
||||
this.status = 1
|
||||
this.audioContext.onCanplay(function (s) {
|
||||
var e = setInterval(function () {
|
||||
var i = parseFloat(that.audioContext.currentTime) / parseFloat(that.audioContext.duration) * 100 + "%", s = Math.floor(Math.ceil(that.audioContext.currentTime) / 60), o = (Math.ceil(that.audioContext.currentTime) % 60 / 100).toFixed(2).slice(-2), r = Math.ceil(that.audioContext.currentTime);
|
||||
s < 10 && (s = "0" + s);
|
||||
var u = s + ":" + o, c = that.audios;
|
||||
c[t].audiowidth = i, c[t].Time = e, c[t].audiotime = u, c[t].seconds = r
|
||||
that.audios = c
|
||||
that.audiotime = that.audios[that.value.id].audiotime
|
||||
that.audiowidth = that.audios[that.value.id].audiowidth
|
||||
that.lyg = i
|
||||
}, 1e3);
|
||||
});
|
||||
|
||||
|
||||
this.audioContext.onEnded(() => {
|
||||
this.status = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fui-audio {
|
||||
width: 100%;
|
||||
border: 1rpx solid #eeeeee;
|
||||
padding: 0 30rpx 0 20rpx;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fui-audio .img {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.fui-audio .img image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fui-audio .name {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fui-audio .author {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.fui-audio .time {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.fui-audio .start {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 2rpx;
|
||||
width: 0;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.fui-audio.style1 {
|
||||
height: 86rpx;
|
||||
line-height: 82rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style1 .img,
|
||||
.fui-audio.style2 .img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fui-audio.style1 .name,
|
||||
.fui-audio.style2 .name {
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style1 .author,
|
||||
.fui-audio.style2 .author {
|
||||
float: left;
|
||||
margin-left: 12rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style1 .time,
|
||||
.fui-audio.style4 .time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fui-audio.style1 .start {
|
||||
position: absolute;
|
||||
top: 0rpx;
|
||||
right: 40rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.fui-audio.style1 .progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fui-audio.style2 {
|
||||
height: 86rpx;
|
||||
line-height: 82rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style2 .img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fui-audio.style2 .time {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 30rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style2 .name {
|
||||
margin-left: 70rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style2 .start {
|
||||
position: absolute;
|
||||
top: 0rpx;
|
||||
left: 30rpx;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.fui-audio.style2 .progress,
|
||||
.fui-audio.style3 .progress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fui-audio.style3 {
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .start {
|
||||
position: absolute;
|
||||
top: 30rpx;
|
||||
left: 28rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
color: #fff;
|
||||
/* border: 2rpx solid #fff; */
|
||||
border-radius: 50%;
|
||||
text-indent: 18rpx;
|
||||
line-height: 56rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .img,
|
||||
.fui-audio.style4 .img {
|
||||
float: left;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .content {
|
||||
width: 468rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .content,
|
||||
.fui-audio.style4 .content {
|
||||
float: left;
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .content .name {
|
||||
height: 40rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .content .author {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fui-audio.style3 .time {
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
right: 30rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style4 {
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style4 .content {
|
||||
padding-bottom: 18rpx;
|
||||
height: 82rpx;
|
||||
width: 500rpx;
|
||||
}
|
||||
|
||||
.fui-audio.style4 .start {
|
||||
position: absolute;
|
||||
top: 32rpx;
|
||||
right: 30rpx;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.fui-audio.style4 .name {
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fui-audio.style4 .author {
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fui-audio.style4 .progress {
|
||||
background: #f5f5f5;
|
||||
height: 4rpx;
|
||||
position: absolute;
|
||||
bottom: 24rpx;
|
||||
left: 130rpx;
|
||||
right: 30rpx;
|
||||
border-radius: 2rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fui-audio.style4 .progressBar {
|
||||
height: 4rpx;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.diy-audio>>>.uni-video-container {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,790 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-bargain" :type="skeletonType" :loading="loading" :configs="skeletonConfig">
|
||||
<view class="diy-bargain" :class="[value.template, value.style]" :style="warpCss">
|
||||
|
||||
<!-- 商品头部 -->
|
||||
<view v-if="value.titleStyle.isShow && list && list.length"
|
||||
:class="[value.titleStyle.style, 'bargain-head']"
|
||||
:style="{ backgroundImage: 'url(' + $util.img(value.titleStyle.backgroundImage) + '), linear-gradient(to right,' + value.titleStyle.bgColorStart + ',' + value.titleStyle.bgColorEnd + ')' }">
|
||||
<view v-if="value.titleStyle.leftStyle == 'text'" class="left-text"
|
||||
:style="{ fontSize: value.titleStyle.fontSize * 2 + 'rpx', color: value.titleStyle.textColor, fontWeight: value.titleStyle.fontWeight ? 'bold' : '' }">
|
||||
{{ value.titleStyle.leftText }}
|
||||
</view>
|
||||
<image v-else class="left-img" :src="$util.img(value.titleStyle.leftImg)" mode="heightFix" show-menu-by-longpress></image>
|
||||
<view class="head-content" v-if="value.titleStyle.style == 'style-1'"
|
||||
:style="{ color: value.titleStyle.textColor }">{{ $lang('components-diy.diy-bargain.freeGet') }}</view>
|
||||
<view class="head-right"
|
||||
:style="{ fontSize: value.titleStyle.moreFontSize * 2 + 'rpx', color: value.titleStyle.moreColor }"
|
||||
@tap.stop="$util.redirectTo('/pages_promotion/bargain/list')">
|
||||
<text>{{ value.titleStyle.more }}</text>
|
||||
<text class="iconfont icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<template v-if="value.template == 'row1-of1'">
|
||||
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
|
||||
:class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(index)" show-menu-by-longpress/>
|
||||
</view>
|
||||
<view class="content"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.btnStyle.control">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="progress" v-if="value.style == 'style-2'">
|
||||
<view class="bg">
|
||||
<view class="curr" :style="{ width: progress(item) * 2 + 'rpx' }">
|
||||
<image class="progress-bar" mode="widthFix"
|
||||
:src="$util.img('public/uniapp/bargain/progress_bar_01.png')" show-menu-by-longpress/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="num" v-if="item.is_bargaining">
|
||||
{{ $lang('components-diy.diy-bargain.bargained') }}
|
||||
<text>¥{{ (item.price - item.curr_price).toFixed(2) }}</text>
|
||||
{{ $lang('components-diy.diy-bargain.onlyDiff') }}
|
||||
<text>¥{{ item.curr_price }}</text>
|
||||
</view>
|
||||
<view class="num" v-else>
|
||||
{{ $lang('components-diy.diy-bargain.minBargainTo') }}
|
||||
<text>¥{{ item.floor_price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress" v-if="value.style == 'style-3'">
|
||||
{{ $lang('components-diy.diy-bargain.minBargainTo') }}
|
||||
<text class="num">¥{{ item.floor_price }}</text>
|
||||
</view>
|
||||
<view class="price-wrap">
|
||||
<view class="discount-price" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">.{{
|
||||
item.price.split('.')[1] }}</text>
|
||||
</view>
|
||||
<button v-if="value.btnStyle.control" :style="{
|
||||
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
|
||||
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
|
||||
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
|
||||
}">
|
||||
{{ item.is_bargaining ? $lang('components-diy.diy-bargain.continueBargain') : value.btnStyle.text }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-if="value.template == 'horizontal-slide'">
|
||||
<scroll-view v-if="value.slideMode == 'scroll'" class="scroll" :scroll-x="true" :show-scrollbar="false">
|
||||
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
|
||||
:class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(index)" :lazy-load="true" show-menu-by-longpress/>
|
||||
<image class="bg"
|
||||
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
|
||||
:src="$util.img('public/uniapp/bargain/bg.png')" mode="widthFix" />
|
||||
<view class="num"
|
||||
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
|
||||
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">
|
||||
{{ $lang('components-diy.diy-bargain.soldCount', { num: item.sale_num }) }}
|
||||
</view>
|
||||
</view>
|
||||
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="discount-price"
|
||||
v-if="value.priceStyle.mainControl && value.template == 'horizontal-slide' && value.style != 'style-2'">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.floor_price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
'.' + item.floor_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
|
||||
¥{{ item.price }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<swiper v-if="value.slideMode == 'slide'" :autoplay="false" class="swiper"
|
||||
:style="{ height: swiperHeight }">
|
||||
<swiper-item v-for="(pageItem, pageIndex) in page" :key="pageIndex"
|
||||
:class="['swiper-item', (list.length && [list[pageIndex].length / 3] >= 1) && 'flex-between']">
|
||||
<view class="item" v-for="(item, dataIndex) in list[pageIndex]" :key="dataIndex"
|
||||
@tap.stop="toDetail(item)" :class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(dataIndex)" :lazy-load="true" show-menu-by-longpress/>
|
||||
<image class="bg"
|
||||
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
|
||||
:src="$util.img('public/uniapp/bargain/bg.png')" mode="widthFix" />
|
||||
<view class="num"
|
||||
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
|
||||
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">
|
||||
{{ $lang('components-diy.diy-bargain.soldCount', { num: item.sale_num }) }}
|
||||
</view>
|
||||
</view>
|
||||
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="discount-price"
|
||||
v-if="value.priceStyle.mainControl && value.template == 'horizontal-slide' && value.style != 'style-2'">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.floor_price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
'.' + item.floor_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
|
||||
¥{{ item.price }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</template>
|
||||
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
// 砍价商品
|
||||
export default {
|
||||
name: 'diy-bargain',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
list: [],
|
||||
page: 1,
|
||||
loading: true,
|
||||
skeletonType: '',
|
||||
skeletonConfig: {}
|
||||
};
|
||||
},
|
||||
components: {},
|
||||
async created() {
|
||||
this.initSkeleton();
|
||||
if (this.value.template == 'row1-of1' && this.value.style == 'style-2') await this.getDataing();
|
||||
this.getData();
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: async function (nval) {
|
||||
if (this.value.template == 'row1-of1' && this.value.style == 'style-2') await this.getDataing();
|
||||
this.getData();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
if (this.value.componentBgColor) obj += 'background:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 商品项样式
|
||||
goodsItemCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.value.ornament.type == 'shadow') {
|
||||
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color + ';';
|
||||
}
|
||||
if (this.value.ornament.type == 'stroke') {
|
||||
obj += 'border:' + '2rpx solid ' + this.value.ornament.color + ';';
|
||||
}
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
if (this.value.template == 'horizontal-slide') {
|
||||
var width = '';
|
||||
if (this.value.slideMode == 'scroll' && this.value.goodsMarginType == 'diy') width = this.rpxUpPx(this
|
||||
.value.goodsMarginNum * 2);
|
||||
else width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this.rpxUpPx(this.value
|
||||
.margin.both * 2) * 2] / 6;
|
||||
|
||||
obj += 'margin-left:' + width + 'px;';
|
||||
obj += 'margin-right:' + width + 'px;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
swiperHeight() {
|
||||
if (this.value.nameLineMode == 'multiple') {
|
||||
if (this.value.ornament.type == 'shadow') return '420rpx';
|
||||
else return '400rpx';
|
||||
}
|
||||
if (this.value.ornament.type == 'shadow') return '386rpx';
|
||||
else return '378rpx';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initSkeleton() {
|
||||
if (this.value.template == 'row1-of1') {
|
||||
|
||||
// 单列 风格
|
||||
this.skeletonType = 'list';
|
||||
this.skeletonConfig = {
|
||||
textRows: 2
|
||||
};
|
||||
|
||||
} else if (this.value.template == 'horizontal-slide') {
|
||||
|
||||
// 横向滑动 风格
|
||||
this.skeletonType = 'waterfall';
|
||||
this.skeletonConfig = {
|
||||
gridRows: 1,
|
||||
gridColumns: 3,
|
||||
headHeight: '200rpx',
|
||||
textRows: 2,
|
||||
textWidth: ['100%', '80%']
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
rpxUpPx(res) {
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
var data = (screenWidth * parseInt(res)) / 750;
|
||||
return Math.floor(data);
|
||||
},
|
||||
// 查找自己参与的砍价
|
||||
async getDataing() {
|
||||
var res = await this.$api.sendRequest({
|
||||
url: '/bargain/api/goods/bargainingList',
|
||||
data: {},
|
||||
async: false
|
||||
});
|
||||
|
||||
res.data &&
|
||||
res.data.forEach((item, index) => {
|
||||
item.is_bargaining = 1;
|
||||
});
|
||||
this.list = res.data || [];
|
||||
this.loading = false;
|
||||
},
|
||||
// 查找可砍价的商品
|
||||
getData() {
|
||||
var data = {
|
||||
num: this.value.count,
|
||||
is_exclude_bargaining: 1
|
||||
};
|
||||
if (this.value.sources == 'diy') {
|
||||
data.num = 0;
|
||||
data.id_arr = this.value.goodsId.toString();
|
||||
}
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/bargain/api/goods/lists',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
if (this.value.template == 'row1-of1' && this.value.style == 'style-2') this.list =
|
||||
this.list.concat(res.data).splice(0, this.value.count);
|
||||
else this.list = res.data;
|
||||
|
||||
// 切屏滚动,每页显示固定数量
|
||||
if (this.value.template == 'horizontal-slide' && this.value.slideMode == 'slide') {
|
||||
let size = 3;
|
||||
let temp = [];
|
||||
this.page = Math.ceil(this.list.length / size);
|
||||
for (var i = 0; i < this.page; i++) {
|
||||
temp[i] = [];
|
||||
for (var j = i * size; j < this.list.length; j++) {
|
||||
if (temp[i].length == size) break;
|
||||
temp[i].push(this.list[j]);
|
||||
}
|
||||
}
|
||||
this.list = temp;
|
||||
}
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
progress(data) {
|
||||
// 214 表示当前进度条的宽度
|
||||
let progress = (((parseFloat(data.price) - parseFloat(data.curr_price)) / parseFloat(data.price)) * 214)
|
||||
.toFixed();
|
||||
if (progress == 'NaN') {
|
||||
progress = 0;
|
||||
}
|
||||
return progress;
|
||||
},
|
||||
toDetail(e) {
|
||||
this.$util.redirectTo('/pages_promotion/bargain/detail', {
|
||||
b_id: e.bargain_id
|
||||
});
|
||||
},
|
||||
imageError(index) {
|
||||
this.list[index].goods_image = this.$util.getDefaultImage().goods;
|
||||
this.$forceUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
::v-deep .uni-scroll-view ::-webkit-scrollbar {
|
||||
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::v-deep ::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
scroll-view ::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.diy-bargain {
|
||||
overflow: hidden;
|
||||
|
||||
// 头部
|
||||
.bargain-head {
|
||||
&.style-1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20rpx;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 18rpx 18rpx 0 0;
|
||||
|
||||
.left-img {
|
||||
height: 40rpx;
|
||||
width: 156rpx;
|
||||
}
|
||||
|
||||
.head-content {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
font-size: $font-size-tag;
|
||||
margin-right: auto;
|
||||
margin-left: 20rpx;
|
||||
line-height: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2rpx;
|
||||
height: 24rpx;
|
||||
background-color: #fff;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: -12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: $font-size-sub;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.style-2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20rpx;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 18rpx 18rpx 0 0;
|
||||
|
||||
.left-img {
|
||||
height: 40rpx;
|
||||
width: 156rpx;
|
||||
}
|
||||
|
||||
.head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 36rpx;
|
||||
// P0: 砍价头渐变 → var(--pintuan-label-grad-start/end) 复用(同色系)
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — linear-gradient 嵌套 var() 现代 runtime 支持
|
||||
background: #ffbd5b; // 兜底
|
||||
background: linear-gradient(270deg, var(--pintuan-label-grad-start, #ffbd5b) 0%, var(--pintuan-label-grad-end, #fd882e) 100%);
|
||||
border-radius: 24rpx;
|
||||
padding: 2rpx;
|
||||
|
||||
text:nth-child(1) {
|
||||
position: relative;
|
||||
left: 6rpx;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
text:nth-child(2) {
|
||||
padding: 6rpx 4rpx 4rpx;
|
||||
background-color: #fff;
|
||||
// P0: 砍价头文字色 → var(--pintuan-label-grad-start) 复用
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — color 接受 var() 无问题
|
||||
color: var(--pintuan-label-grad-start, #ffbd5b);
|
||||
border-radius: 50%;
|
||||
transform: scale(0.6);
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品列表
|
||||
&.row1-of1 {
|
||||
.item {
|
||||
display: flex;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 16rpx;
|
||||
|
||||
&.shadow {
|
||||
margin: 8rpx 8rpx 20rpx 8rpx;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
|
||||
>image {
|
||||
width: 200rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 6rpx 0 6rpx 20rpx;
|
||||
|
||||
.goods-name {
|
||||
&.multi-hidden {
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.price-wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.discount-price {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 4rpx;
|
||||
color: var(--price-color);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
color: var(--price-color);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
color: var(--btn-text-color);
|
||||
background-color: $base-color;
|
||||
color: #fff;
|
||||
min-width: 112rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
font-size: $font-size-tag;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.style-2 {
|
||||
.discount-price {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 10rpx;
|
||||
margin-right: 16rpx;
|
||||
|
||||
.bg {
|
||||
margin-left: 6rpx;
|
||||
width: auto;
|
||||
height: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
// P0: 砍价浅底 → var(--bargain-promo-shallow)
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — background-color 接受 var() 无问题
|
||||
background-color: var(--bargain-promo-shallow, #ffeadb);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 50%;
|
||||
// P0: 砍价红圆点 → var(--bargain-promo-color)
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — background-color 接受 var() 无问题
|
||||
background-color: var(--bargain-promo-color, #fa1a1a);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: -18rpx;
|
||||
}
|
||||
|
||||
.curr {
|
||||
width: 0;
|
||||
height: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
// P0: 砍价红进度条 → var(--bargain-promo-color)
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — background-color 接受 var() 无问题
|
||||
background-color: var(--bargain-promo-color, #fa1a1a);
|
||||
position: relative;
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
right: -20rpx;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
max-width: inherit !important;
|
||||
max-height: inherit !important;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.num {
|
||||
font-size: $font-size-tag;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1;
|
||||
|
||||
text {
|
||||
// P0: 砍价数字红 → var(--bargain-promo-color)
|
||||
// 兼容: 微信小程序 / 鸿蒙元服务 — color 接受 var() 无问题
|
||||
color: var(--bargain-promo-color, #fa1a1a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.style-3 {
|
||||
.progress {
|
||||
display: flex;
|
||||
color: $color-tip;
|
||||
font-size: $font-size-sub;
|
||||
|
||||
.num {
|
||||
color: var(--price-color);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
.content {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal-slide {
|
||||
.scroll {
|
||||
width: calc(100% - 40rpx);
|
||||
padding: 20rpx;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.item.shadow {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-block;
|
||||
width: 200rpx;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(3n + 3) {
|
||||
width: 198rpx;
|
||||
}
|
||||
|
||||
&.shadow {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
|
||||
>image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 60rpx;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.num {
|
||||
width: 180rpx;
|
||||
position: absolute;
|
||||
bottom: 10rpx;
|
||||
padding-left: 20rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&.multi-content {
|
||||
height: 160rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
line-height: 1.3;
|
||||
|
||||
&.multi-hidden {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
|
||||
.discount-price {
|
||||
white-space: nowrap;
|
||||
margin-top: auto;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 4rpx;
|
||||
color: var(--price-color);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
color: var(--price-color);
|
||||
}
|
||||
}
|
||||
|
||||
.original-price {
|
||||
margin-top: 4rpx;
|
||||
font-size: $font-size-tag;
|
||||
color: $color-tip;
|
||||
line-height: 1;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper {
|
||||
padding: 20rpx;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
|
||||
.swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 200rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,463 +0,0 @@
|
||||
<template>
|
||||
<!-- 修复底部 TabBar 频闪:
|
||||
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
|
||||
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
|
||||
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
|
||||
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
|
||||
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
|
||||
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
|
||||
<view class="tabbar-border"></view>
|
||||
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
|
||||
<view class="bd">
|
||||
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
|
||||
:animation="isCartItem(item.link) ? cartAnimation : null"
|
||||
:id="isCartItem(item.link) ? 'tabbarCart' : null">
|
||||
<block v-if="verify(item.link)">
|
||||
<image v-if="item.selected_icon_type == 'img'"
|
||||
:src="$util.img(item.selectedIconPath)" />
|
||||
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
|
||||
:value="item.selected_style ? item.selected_style : null"></diy-icon>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
|
||||
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
|
||||
:value="item.style ? item.style : null"></diy-icon>
|
||||
</block>
|
||||
<view class="cart-count-mark font-size-activity-tag"
|
||||
:class="{ max: cartNumber > 99 }"
|
||||
:style="{ background: 'var(--price-color)' }"
|
||||
v-if="isCartItem(item.link) && cartNumber > 0">
|
||||
{{ cartNumber > 99 ? '99+' : cartNumber }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="label"
|
||||
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
|
||||
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
|
||||
{{ $langConfig.getField(item, 'text') }}
|
||||
</view>
|
||||
<view class="label"
|
||||
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'default'"
|
||||
:style="{ color: verify(item.link) ? 'var(--base-color)' : '#333333' }">
|
||||
{{ $langConfig.getField(item, 'text') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
|
||||
<view class="tab-bar-placeholder"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { adaptSubpackageUrl, checkTabBarActive, decodeEntity } from '@/common/js/util.js'
|
||||
import { resolveEntryTab } from '@/common/js/util-tabbar.js'
|
||||
import DiyMinx from './minx.js'
|
||||
// 底部导航栏
|
||||
export default {
|
||||
name: 'diy-bottom-nav',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
currentRoute: '', //当前页面路径
|
||||
jumpFlag: true, //是否可以跳转,防止重复点击
|
||||
cartAnimation: {}
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.updateCurrentRoute();
|
||||
this.$nextTick(() => {
|
||||
if (!this.$store.state.cartPosition) {
|
||||
let query = uni.createSelectorQuery().in(this);
|
||||
query.select('#tabbarCart')
|
||||
.boundingClientRect(data => {
|
||||
if (data) this.$store.commit('setCartPosition', data);
|
||||
}).exec();
|
||||
|
||||
query.select('.tab-bar')
|
||||
.boundingClientRect(data => {
|
||||
if (data) this.$store.commit('setTabBarHeight', data.height +
|
||||
'px');
|
||||
}).exec();
|
||||
}
|
||||
});
|
||||
// 监听页面显示事件,更新当前路由
|
||||
this.$on('hook:onShow', () => {
|
||||
this.updateCurrentRoute();
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
// 当前语言(响应式):通过 componentRefresh 建立依赖,切语言时自动更新
|
||||
// 不能直接用 uni.getStorageSync 作为 data,因为它不是响应式的
|
||||
currentLang() {
|
||||
// 通过 componentRefresh 建立响应式依赖
|
||||
// 切语言时 store 中的 componentRefresh 会 +1,触发重新计算
|
||||
this.componentRefresh;
|
||||
return uni.getStorageSync("lang") || 'zh-cn';
|
||||
},
|
||||
cartChange() {
|
||||
return this.$store.state.cartChange;
|
||||
},
|
||||
packageEntryTab() {
|
||||
return this.$store.state.packageEntryTab;
|
||||
},
|
||||
// 响应 tabBarList 异步加载: 后端返回前 updateCurrentRoute 是 no-op,
|
||||
// tabBarList 就绪后必须重跑一次, 否则冷启动+秒点链接会出现"先不高亮→再突然高亮"
|
||||
tabBarReady() {
|
||||
return this.tabBarList && this.tabBarList.list && this.tabBarList.list.length > 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
cartChange: function (nval, oval) {
|
||||
if (nval > oval) {
|
||||
let animation = uni.createAnimation({
|
||||
duration: 200,
|
||||
timingFunction: 'ease'
|
||||
});
|
||||
animation.scale(1.2).step();
|
||||
this.cartAnimation = animation.export();
|
||||
setTimeout(() => {
|
||||
animation.scale(1).step();
|
||||
this.cartAnimation = animation.export();
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
// tabBarList 异步加载完成后, 补一次 updateCurrentRoute
|
||||
// 修复: 冷启动时用户在 tabBarList 就绪前点击链接, 第一次 updateCurrentRoute 是 no-op
|
||||
tabBarReady: {
|
||||
handler(ready) {
|
||||
if (ready) {
|
||||
this.updateCurrentRoute();
|
||||
}
|
||||
},
|
||||
immediate: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 统一归一化路径:currentPage.route 不带前导 '/',但 tab 的 wap_url 带前导 '/'
|
||||
// 统一加前导 '/' 后再 split,确保 parts[1]=分包名、parts[2]=页面名
|
||||
_normalizeRoute(route) {
|
||||
if (!route) return '';
|
||||
return route.startsWith('/') ? route : '/' + route;
|
||||
},
|
||||
// 从归一化路径中提取分包名(parts[1])
|
||||
_getPackageName(normalizedRoute) {
|
||||
const parts = normalizedRoute.split('?')[0].split('/');
|
||||
return parts[1] || '';
|
||||
},
|
||||
// 判断某个链接是否指向购物车页面
|
||||
isCartItem(link) {
|
||||
if (!link || !link.wap_url) return false;
|
||||
return adaptSubpackageUrl(link.wap_url).split('?')[0] === '/pages_goods/cart';
|
||||
},
|
||||
// 更新当前路由,追踪每个分包的入口 tab
|
||||
updateCurrentRoute() {
|
||||
const currentPages = getCurrentPages();
|
||||
const currentPage = currentPages[currentPages.length - 1];
|
||||
if (!currentPage || !currentPage.route) return;
|
||||
|
||||
// 归一化:补全前导 '/',确保后续 split('/') 后 parts[1]=分包名
|
||||
const newRoute = this._normalizeRoute(currentPage.route);
|
||||
const currentPackageName = newRoute.split('?')[0].split('/')[1] || '';
|
||||
|
||||
if (this.tabBarList && this.tabBarList.list && currentPackageName) {
|
||||
// 收集历史栈路由 (排除当前页, 已 _normalizeRoute)
|
||||
// 注意: 组件里直接传 raw route 给 resolveEntryTab,
|
||||
// 内部 adaptSubpackageUrl 会兜底处理老路径 /pages/member/ 等
|
||||
const historyRoutes = currentPages.length >= 2
|
||||
? currentPages.slice(0, -1).map((p) =>
|
||||
p && p.route ? this._normalizeRoute(p.route) : null)
|
||||
: [];
|
||||
|
||||
// 调用纯函数 resolveEntryTab 完成 4 步查找
|
||||
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
|
||||
// 修复: home → diy 微页面 等跨分包导航被"同分包第一个 tab"误判的问题
|
||||
const entryTab = resolveEntryTab({
|
||||
currentPageRoute: newRoute,
|
||||
tabBarList: this.tabBarList,
|
||||
historyRoutes,
|
||||
// 传 NO_TAB_PAGES 进去, 让 login/pay/AI 等 modal 页不参与归属判定
|
||||
// 例子: home → login → diy 应算作 home → diy (login 是 modal)
|
||||
noTabPages: this.$util && this.$util.NO_TAB_PAGES ? this.$util.NO_TAB_PAGES : null,
|
||||
});
|
||||
|
||||
if (entryTab) {
|
||||
this.$store.commit('setPackageEntryTab', {
|
||||
...this.packageEntryTab,
|
||||
[currentPackageName]: entryTab
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.currentRoute = newRoute;
|
||||
},
|
||||
redirectTo(link) {
|
||||
this.$emit('callback');
|
||||
const redirectLink = link ? { ...link } : link;
|
||||
|
||||
if (link && link.wap_url) {
|
||||
const decodedUrl = decodeEntity(link.wap_url);
|
||||
const adaptedUrl = adaptSubpackageUrl(decodedUrl);
|
||||
const packageName = this._getPackageName(adaptedUrl);
|
||||
|
||||
if (packageName) {
|
||||
this.$store.commit('setPackageEntryTab', {
|
||||
...this.packageEntryTab,
|
||||
[packageName]: adaptedUrl.split('?')[0]
|
||||
});
|
||||
}
|
||||
|
||||
// 若目标页面不是 TabBar 根页面,追加 from_tab 来源参数,
|
||||
// 用于非 Tab 页面的 TabBar 高亮记忆
|
||||
if (this.tabBarList && this.tabBarList.list) {
|
||||
const tabUrls = this.tabBarList.list
|
||||
.filter(item => item.link && item.link.wap_url)
|
||||
.map(item => adaptSubpackageUrl(decodeEntity(item.link.wap_url)).split('?')[0]);
|
||||
if (!tabUrls.includes(adaptedUrl.split('?')[0])) {
|
||||
const currentPages = getCurrentPages();
|
||||
const currentPage = currentPages[currentPages.length - 1];
|
||||
const currentTab = currentPage && currentPage.route
|
||||
? tabUrls.find(tabUrl => checkTabBarActive(tabUrl, '/' + currentPage.route, this.tabBarList, this.packageEntryTab))
|
||||
: null;
|
||||
if (currentTab) {
|
||||
redirectLink.from_tab = currentTab;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.$util.diyRedirectTo(redirectLink);
|
||||
},
|
||||
verify(link) {
|
||||
if (link == null || link == '' || !link.wap_url) return false;
|
||||
|
||||
const currentPages = getCurrentPages();
|
||||
const currentPage = currentPages[currentPages.length - 1];
|
||||
const currentPageRoute = currentPage && currentPage.route
|
||||
? this._normalizeRoute(currentPage.route)
|
||||
: '';
|
||||
|
||||
// 读取当前页面 query 参数(兼容小程序与 H5)
|
||||
let currentPageQuery = {};
|
||||
if (currentPage) {
|
||||
if (currentPage.options) {
|
||||
currentPageQuery = currentPage.options;
|
||||
} else if (currentPage.$page && currentPage.$page.options) {
|
||||
currentPageQuery = currentPage.$page.options;
|
||||
} else if (currentPage.$route && currentPage.$route.query) {
|
||||
currentPageQuery = currentPage.$route.query;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.name === 'DIY_VIEW_INDEX') {
|
||||
// DIY_VIEW_INDEX 短路: 当 name 是 DIY_VIEW_INDEX 时, 任意匹配的 tab 都高亮
|
||||
// 修复前: 只对 HOME (INDEX_PAGE_URL) 生效
|
||||
// 修复后: 适用于所有 tab 页面 (如 merchant DIY 首页、cart DIY 首页 等)
|
||||
// 前提: 当前页面本身就是该 tab 根, 链接 == 当前页 → step 1 早就命中
|
||||
// 这个短路主要兜底: "DIY_VIEW_INDEX 页面 + tab 链接" 的强语义关联
|
||||
const linkUrl = adaptSubpackageUrl(link.wap_url).split('?')[0];
|
||||
const currentUrl = currentPageRoute.split('?')[0];
|
||||
if (linkUrl === currentUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return checkTabBarActive(link.wap_url, currentPageRoute, this.tabBarList, this.packageEntryTab, currentPageQuery);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.placeholder {
|
||||
height: 112rpx;
|
||||
|
||||
&.bluge {
|
||||
height: 180rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area {
|
||||
padding-bottom: 0;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
/* ============================================================
|
||||
* 固定在最底端,不能位置漂移(微信小程序 / 鸿蒙元服务 / 快应用 H5)
|
||||
* ------------------------------------------------------------
|
||||
* 现象(只在客户端小程序里复现,浏览器 H5 没事):
|
||||
* 页面带 navigationStyle:custom + 页面内有 scroll-view,
|
||||
* 滚到底部时 .tab-bar 会被微信客户端的页面避让逻辑推上去,
|
||||
* 出现"位置漂移"。
|
||||
*
|
||||
* 根因(微信小程序特性):
|
||||
* 1) 微信小程序里 position:fixed 的 containing block 是 page
|
||||
* 根容器(不是 viewport),可视区变化时 fixed 元素下沿会
|
||||
* 跟着重算,造成漂移。
|
||||
* 2) 父级一旦出现 transform/filter/will-change,fixed 的
|
||||
* containing block 会被进一步改写,fixed 元素行为更不可控。
|
||||
*
|
||||
* 加固措施:
|
||||
* - bottom: 0 !important 钉死下沿
|
||||
* - 提升 z-index 到 2147483647,避免被其他 fixed 元素遮挡
|
||||
* - transform: translateZ(0) 强制提升到独立合成层,降低父级
|
||||
* transform 干扰;will-change:transform 提示浏览器持续合成
|
||||
* - 微信小程序里 #ifdef MP-WEIXIN / #ifdef MP-HARMONY 走原生
|
||||
* tabBar,diy-bottom-nav 通常不会同时存在,但保留兜底
|
||||
* ============================================================ */
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
top: auto !important;
|
||||
width: 100%;
|
||||
z-index: 2147483647 !important;
|
||||
display: flex;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
/* 强制提升合成层,降低父级 transform/filter 改变 containing block 的概率 */
|
||||
transform: translateZ(0) !important;
|
||||
-webkit-transform: translateZ(0) !important;
|
||||
will-change: transform !important;
|
||||
padding-bottom: 0;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
.tabbar-border {
|
||||
background-color: rgba(255, 255, 255, 0.329412);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 2rpx;
|
||||
-webkit-transform: scaleY(0.5);
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-direction: normal;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding-bottom: 10rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.bd {
|
||||
position: relative;
|
||||
height: 100rpx;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-top: 10rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
font-size: 40rpx;
|
||||
line-height: 40rpx;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
>view {
|
||||
height: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar-icon {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.bulge {
|
||||
.bd {
|
||||
position: relative;
|
||||
height: 100rpx;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
margin-top: -60rpx;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 50%;
|
||||
width: 100rpx;
|
||||
height: 102rpx;
|
||||
padding: 10rpx;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
height: 40rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cart-count-mark {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -18rpx;
|
||||
width: 24rpx;
|
||||
height: 24rpx !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
padding: 6rpx;
|
||||
border-radius: 50%;
|
||||
z-index: 99;
|
||||
|
||||
&.max {
|
||||
width: 40rpx;
|
||||
border-radius: 24rpx;
|
||||
right: -28rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar-placeholder {
|
||||
padding-bottom: calc(constant(safe-area-inset-bottom) + 112rpx);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 112rpx);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,631 +0,0 @@
|
||||
<template>
|
||||
<view class="channel-list-container" :style="[componentStyle, { '--row-count': value.rowCount }]">
|
||||
<!-- 轮播模式 -->
|
||||
<swiper v-if="value.showStyle == 'carousel' && carouselConfig.type != 'hide'" :indicator-dots="isIndicatorDots"
|
||||
:autoplay="carouselConfig.autoplay || false" :interval="carouselConfig.interval || 3000"
|
||||
:duration="carouselConfig.duration || 500" :circular="carouselConfig.circular || false"
|
||||
:style="swiperHeight" class="channel-swiper">
|
||||
<swiper-item v-for="(slide, slideIndex) in swiperSlides" :key="slideIndex">
|
||||
<view :class="['swiper-slide-content', 'row1-of' + value.rowCount]">
|
||||
<view v-for="(item, index) in slide" :key="index" :class="['channel-item', value.mode]">
|
||||
<!-- 视频号视频卡片,轮播模式 -->
|
||||
<diy-channel-video :value="item" @video-play="playVideo" :list-mode="true"
|
||||
:title-line-clamp="value.titleLineClamp" :show-play-btn="value.showPlayBtn"
|
||||
:show-view-count="value.showViewCount" :cover-style="computedCoverStyle"
|
||||
:play-btn-style="value.playBtnStyle" :aspect-ratio="value.aspectRatio" />
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 固定布局模式 -->
|
||||
<view v-else-if="value.showStyle == 'fixed'" :class="['channel-list', 'row1-of' + value.rowCount]">
|
||||
<view v-for="(item, index) in value.list" :key="index" class="channel-item">
|
||||
<!-- 视频号视频卡片,列表模式 -->
|
||||
<diy-channel-video :value="item" @video-play="playVideo" :list-mode="true"
|
||||
:title-line-clamp="value.titleLineClamp" :show-play-btn="value.showPlayBtn"
|
||||
:show-view-count="value.showViewCount" :cover-style="computedCoverStyle"
|
||||
:play-btn-style="value.playBtnStyle" :aspect-ratio="value.aspectRatio" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他布局模式(如滚动布局) -->
|
||||
<scroll-view v-else
|
||||
:class="['channel-nav', value.showStyle == 'fixed' ? 'fixed-layout' : 'singleSlide', 'row1-of' + value.rowCount]"
|
||||
:scroll-x="true"
|
||||
:scroll-y="false"
|
||||
:enhanced="true"
|
||||
:bounces="false">
|
||||
<view class="uni-scroll-view-content">
|
||||
<view v-for="(item, index) in value.list" :key="index"
|
||||
:class="['channel-nav-item', value.mode, 'row1-of' + value.rowCount]">
|
||||
<!-- 视频号视频卡片,滚动模式 -->
|
||||
<diy-channel-video :value="item" @video-play="playVideo" :list-mode="true"
|
||||
:title-line-clamp="value.titleLineClamp" :show-play-btn="value.showPlayBtn"
|
||||
:show-view-count="value.showViewCount" :cover-style="computedCoverStyle"
|
||||
:play-btn-style="value.playBtnStyle" :aspect-ratio="value.aspectRatio" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 微信视频号列表组件
|
||||
* 支持多种布局模式,包括固定布局和滚动布局
|
||||
* 可配置列数、视频高度、标题行数等属性
|
||||
* 用于在页面中展示多个微信视频号视频
|
||||
*/
|
||||
import DiyMinx from './minx.js'
|
||||
import { wechatChannelUtil, wechatChannelConfig } from './js/wechat-channel.js'
|
||||
|
||||
export default {
|
||||
name: 'diy-channel-list',
|
||||
props: {
|
||||
/**
|
||||
* 组件配置数据
|
||||
* @type {Object}
|
||||
* @default () => ({})
|
||||
* @property {string} showStyle - 显示样式,可选值:carousel, fixed, singleSlide
|
||||
* @property {number} rowCount - 每行显示的视频数量
|
||||
* @property {Array} list - 视频列表数据
|
||||
* @property {string} componentBgColor - 组件背景色
|
||||
* @property {string} componentAngle - 组件圆角类型,可选值:round
|
||||
* @property {number} topAroundRadius - 顶部圆角半径
|
||||
* @property {number} bottomAroundRadius - 底部圆角半径
|
||||
* @property {Object} ornament - 装饰效果配置
|
||||
* @property {boolean} showViewCount - 是否显示播放量,可选值:true, false
|
||||
* @property {number} titleLineClamp - 标题显示行数
|
||||
* @property {string} aspectRatio - 视频比例,可选值:16:9, 3:4
|
||||
* @property {boolean} showPlayBtn - 是否显示播放按钮
|
||||
* @property {Object} coverStyle - 视频封面图样式
|
||||
* @property {Object} playBtnStyle - 播放按钮样式
|
||||
* @property {string} mode - 显示模式, 可选值:video, text, image, graphic
|
||||
* @property {number} imageSize - 图片尺寸(仅在特定模式下使用)
|
||||
* @property {number} pageCount - 每页显示的视频数量
|
||||
* @property {Object} carousel - 轮播配置
|
||||
* @property {string} carousel.type - 轮播类型,可选值:default, hide
|
||||
* @property {boolean} carousel.autoplay - 是否自动播放,默认false
|
||||
* @property {number} carousel.interval - 自动播放间隔,默认3000ms
|
||||
* @property {number} carousel.duration - 切换动画时长,默认500ms
|
||||
* @property {boolean} carousel.circular - 是否循环播放,默认false
|
||||
*/
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
pageWidth: '', // 页面宽度
|
||||
indicatorDots: false, // 是否显示轮播指示器
|
||||
swiperCurrent: 0 // 当前轮播索引
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 组件创建时的逻辑
|
||||
// 可以在这里进行初始化操作,如获取页面宽度等
|
||||
},
|
||||
mounted() {
|
||||
// 组件挂载后添加鼠标拖拽滚动功能
|
||||
if (!['fixed', 'carousel'].includes(this.value?.showStyle)) {
|
||||
this.$nextTick(() => {
|
||||
this.addMouseDragScroll();
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
/**
|
||||
* 组件刷新监听
|
||||
* 当组件需要刷新时触发
|
||||
* @param {*} newValue - 新值
|
||||
*/
|
||||
componentRefresh(newValue) {
|
||||
// 监听组件刷新
|
||||
// 可以在这里处理组件刷新时的逻辑
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
carouselConfig() {
|
||||
return this.value?.carousel || {
|
||||
type: 'default',
|
||||
autoplay: false,
|
||||
interval: 3000,
|
||||
duration: 500,
|
||||
circular: true
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 组件样式
|
||||
* 根据配置动态生成样式字符串,包括背景色、圆角、阴影和边框
|
||||
* @returns {string} 样式字符串
|
||||
*/
|
||||
componentStyle() {
|
||||
const style = {};
|
||||
// 背景色
|
||||
if (this.value?.componentBgColor) {
|
||||
style.backgroundColor = this.value?.componentBgColor;
|
||||
}
|
||||
|
||||
// 圆角样式
|
||||
if (this.value?.componentAngle == 'round') {
|
||||
style.borderTopLeftRadius = (2 * this.value?.topAroundRadius) + 'rpx';
|
||||
style.borderTopRightRadius = (2 * this.value?.topAroundRadius) + 'rpx';
|
||||
style.borderBottomLeftRadius = (2 * this.value?.bottomAroundRadius) + 'rpx';
|
||||
style.borderBottomRightRadius = (2 * this.value?.bottomAroundRadius) + 'rpx';
|
||||
}
|
||||
|
||||
// 装饰效果:阴影
|
||||
style.boxShadow = this.value?.ornament?.type == 'shadow' ? '0 0 10rpx ' + this.value?.ornament?.color : '';
|
||||
// 装饰效果:边框
|
||||
style.border = this.value?.ornament?.type == 'stroke' ? '2rpx solid ' + this.value?.ornament?.color : '';
|
||||
|
||||
return style;
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮播高度
|
||||
* 根据模式和配置计算轮播高度
|
||||
* @returns {string} 轮播高度样式
|
||||
*/
|
||||
swiperHeight() {
|
||||
let height = 0;
|
||||
|
||||
const pageCount = this.value?.pageCount || 1;
|
||||
|
||||
// 根据不同模式计算高度
|
||||
if (this.value?.mode == 'graphic') {
|
||||
height = (49 + this.value?.imageSize) * pageCount;
|
||||
} else if (this.value?.mode == 'img') {
|
||||
height = (22 + this.value?.imageSize) * pageCount;
|
||||
} else if (this.value?.mode == 'text') {
|
||||
height = 43 * pageCount;
|
||||
} else { // 默认视频模式
|
||||
if (this.value?.aspectRatio == '16:9') {
|
||||
height = [250, 160, 140, 120][this.value.rowCount - 1];
|
||||
} else {
|
||||
height = [510, 280, 220, 180][this.value.rowCount - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return 'height:' + (2 * height) + 'rpx';
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否显示指示器
|
||||
* 根据轮播配置和列表长度判断是否显示指示器
|
||||
* @returns {boolean} 是否显示指示器
|
||||
*/
|
||||
isIndicatorDots() {
|
||||
// 当轮播类型不是隐藏,且视频数量超过一页时显示指示器
|
||||
const pageCount = this.value?.pageCount || 1;
|
||||
return this.value?.carousel?.type != 'hide' && Math.ceil(this.value?.list?.length / (pageCount * this.value?.rowCount)) > 1
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮播幻灯片数据
|
||||
* 将视频列表分割成轮播所需的幻灯片数据
|
||||
* @returns {Array} 轮播幻灯片数据
|
||||
*/
|
||||
swiperSlides() {
|
||||
const slides = [];
|
||||
const list = this.value?.list || [];
|
||||
const pageSize = (this.value?.pageCount || 1) * (this.value?.rowCount || 1);
|
||||
|
||||
// 将列表数据分割成每页显示的数量
|
||||
for (let i = 0; i < list.length; i += pageSize) {
|
||||
slides.push(list.slice(i, i + pageSize));
|
||||
}
|
||||
|
||||
return slides;
|
||||
},
|
||||
/**
|
||||
* 视频封面样式
|
||||
* 根据aspectRatio属性动态计算封面样式
|
||||
* @returns {Object} 封面样式对象
|
||||
*/
|
||||
computedCoverStyle() {
|
||||
const aspectRatio = this.value?.aspectRatio || '16:9';
|
||||
let paddingTop = '56.25%'; // 默认 16:9 比例
|
||||
|
||||
if (aspectRatio === '3:4') {
|
||||
paddingTop = '133.33%'; // 3:4 比例
|
||||
}
|
||||
|
||||
return {
|
||||
width: '100%',
|
||||
height: '0',
|
||||
paddingTop: paddingTop
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 播放视频
|
||||
* 触发 video-play 事件,并在微信小程序中调用视频播放接口
|
||||
* @param {Object} item - 视频数据对象
|
||||
*/
|
||||
async playVideo(item) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'video-play',
|
||||
data: item,
|
||||
promiseCallback: async (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
try {
|
||||
// 发送视频被点击播放事件
|
||||
this.$emit('channel-video-click-play', item);
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 在微信小程序环境中调用视频播放接口
|
||||
await wechatChannelUtil.playVideo(item);
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
if (item?.channelType === 'wechat') {
|
||||
uni.showToast({
|
||||
title: '视频号仅支持在微信小程序环境中播放',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
} else {
|
||||
// 在非微信小程序环境中,直接触发事件
|
||||
this.$emit('video-play', item);
|
||||
}
|
||||
// #endif
|
||||
} catch (err) {
|
||||
console.error('打开视频号失败', err);
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 图片加载错误处理
|
||||
* 当图片加载失败时,设置默认图片
|
||||
* @param {number} index - 图片索引
|
||||
*/
|
||||
imgError(index) {
|
||||
// 图片加载失败的处理逻辑
|
||||
// 为失败的图片设置默认图片
|
||||
const item = this.value.list[index];
|
||||
if (item) {
|
||||
// 使用默认图片替代加载失败的图片
|
||||
// #ifdef MP-WEIXIN
|
||||
item.coverUrl = wechatChannelConfig.video.defaultCoverUrl;
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 添加鼠标拖拽滚动功能
|
||||
* 在Web环境中实现与微信小程序相同的拖拽滚动效果
|
||||
*/
|
||||
addMouseDragScroll() {
|
||||
// 只在Web环境中添加
|
||||
// #ifndef MP
|
||||
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startScrollLeft = 0;
|
||||
let currentScrollElement = null;
|
||||
|
||||
// 查找最近的可滚动祖先元素
|
||||
const findScrollableParent = (element) => {
|
||||
while (element && element !== document) {
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.overflowX === 'auto' || style.overflowX === 'scroll') {
|
||||
return element;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 鼠标按下事件
|
||||
const handleMouseDown = (e) => {
|
||||
// 检查是否点击在组件内
|
||||
if (this.$el.contains(e.target)) {
|
||||
// 查找可滚动元素
|
||||
currentScrollElement = findScrollableParent(e.target);
|
||||
if (currentScrollElement) {
|
||||
isDragging = true;
|
||||
startX = e.pageX;
|
||||
startScrollLeft = currentScrollElement.scrollLeft;
|
||||
currentScrollElement.style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标移动事件
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDragging || !currentScrollElement) return;
|
||||
e.preventDefault();
|
||||
const dx = e.pageX - startX;
|
||||
currentScrollElement.scrollLeft = startScrollLeft - dx;
|
||||
};
|
||||
|
||||
// 鼠标释放事件
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging && currentScrollElement) {
|
||||
currentScrollElement.style.cursor = 'grab';
|
||||
}
|
||||
isDragging = false;
|
||||
currentScrollElement = null;
|
||||
};
|
||||
|
||||
// 添加全局事件监听器
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mouseleave', handleMouseUp);
|
||||
|
||||
// 组件销毁时移除事件监听器
|
||||
this.$once('hook:beforeDestroy', () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mouseleave', handleMouseUp);
|
||||
});
|
||||
|
||||
// 为所有.channel-nav元素添加必要的样式
|
||||
setTimeout(() => {
|
||||
const channelNavs = document.querySelectorAll('.channel-nav');
|
||||
channelNavs.forEach(element => {
|
||||
element.style.overflowX = 'auto';
|
||||
element.style.overflowY = 'hidden';
|
||||
element.style.whiteSpace = 'nowrap';
|
||||
element.style.width = '100%';
|
||||
element.style.maxWidth = '100%';
|
||||
element.style.cursor = 'grab';
|
||||
element.style.userSelect = 'none'; // 防止文本选择
|
||||
});
|
||||
}, 100); // 延迟执行,确保DOM已完全渲染
|
||||
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './css/common-channel.scss';
|
||||
|
||||
.channel-list-container {
|
||||
padding: 16px 16px 0px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表布局样式
|
||||
*/
|
||||
.channel-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--row-count, 2), 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航布局样式
|
||||
* 支持固定布局和滚动布局
|
||||
*/
|
||||
.channel-nav {
|
||||
box-sizing: border-box;
|
||||
padding: 16rpx;
|
||||
|
||||
// 确保在H5环境中可以水平滚动
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
// 隐藏滚动条但保留滚动功能
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
// 启用触摸滚动和鼠标拖拽滚动
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
.uni-scroll-view-content {
|
||||
/* #ifdef MP-WEIXIN */
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16rpx;
|
||||
/* #endif */
|
||||
/* #ifndef MP-WEIXIN */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--row-count, 2), 1fr);
|
||||
gap: 16rpx;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
// 单滑动模式
|
||||
&.singleSlide {
|
||||
.uni-scroll-view-content {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-nav-item {
|
||||
flex-shrink: 0;
|
||||
width: 280rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
&.row1-of1 {
|
||||
width: 100%;
|
||||
}
|
||||
&.row1-of2 {
|
||||
width: calc(50% - 8rpx);
|
||||
}
|
||||
&.row1-of3 {
|
||||
width: calc(33.333% - 10.67rpx);
|
||||
}
|
||||
&.row1-of4 {
|
||||
width: calc(25% - 12rpx);
|
||||
}
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
// 1 列布局
|
||||
&.row1-of1 {
|
||||
.uni-scroll-view-content {
|
||||
// #ifndef MP-WEIXIN
|
||||
grid-template-columns: 1fr;
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
// 2 列布局
|
||||
&.row1-of2 {
|
||||
.uni-scroll-view-content {
|
||||
// #ifndef MP-WEIXIN
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
// #endif
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 3 列布局
|
||||
&.row1-of3 {
|
||||
.uni-scroll-view-content {
|
||||
// #ifndef MP-WEIXIN
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
// #endif
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 4 列布局
|
||||
&.row1-of4 {
|
||||
.uni-scroll-view-content {
|
||||
// #ifndef MP-WEIXIN
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
// #endif
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保所有视频卡片高度一致
|
||||
*/
|
||||
.channel-item,
|
||||
.channel-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮播样式
|
||||
* 支持轮播模式的布局和样式
|
||||
*/
|
||||
.channel-swiper {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.swiper-slide-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--row-count, 2), 1fr);
|
||||
gap: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.channel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 轮播模式下的 1 列布局
|
||||
&.row1-of1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
// 轮播模式下的 2 列布局
|
||||
&.row1-of2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
// 轮播模式下的 3 列布局
|
||||
&.row1-of3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
// 轮播模式下的 4 列布局
|
||||
&.row1-of4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式调整
|
||||
* 在小屏幕设备上调整布局和间距
|
||||
*/
|
||||
@media (max-width: 375px) {
|
||||
|
||||
.channel-list,
|
||||
.channel-nav,
|
||||
.channel-swiper .swiper-slide-content {
|
||||
gap: 12rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
// 小屏幕上的单滑动模式
|
||||
.channel-nav {
|
||||
&.singleSlide {
|
||||
.uni-scroll-view-content {
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.channel-nav-item {
|
||||
width: 240rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频比例样式调整
|
||||
* 根据不同的视频比例调整布局
|
||||
*/
|
||||
|
||||
// // 3:4 比例的视频卡片样式
|
||||
// .channel-video.ratio-3-4 {
|
||||
// // 调整视频卡片的整体高度
|
||||
// ::v-deep .video-cover-wrap {
|
||||
// padding-top: 133.33%; // 3:4 比例
|
||||
// }
|
||||
|
||||
// // 列表模式下的3:4比例调整
|
||||
// &.list-mode {
|
||||
// ::v-deep .video-cover-wrap {
|
||||
// padding-top: 133.33%; // 3:4 比例
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 16:9 比例的视频卡片样式(默认)
|
||||
// .channel-video.ratio-16-9 {
|
||||
// // 保持默认的16:9比例
|
||||
// ::v-deep .video-cover-wrap {
|
||||
// padding-top: 56.25%; // 16:9 比例
|
||||
// }
|
||||
|
||||
// // 列表模式下的16:9比例保持默认
|
||||
// &.list-mode {
|
||||
// ::v-deep .video-cover-wrap {
|
||||
// padding-top: 56.25%; // 16:9 比例
|
||||
// }
|
||||
// }
|
||||
// }</style>
|
||||
@@ -1,488 +0,0 @@
|
||||
<template>
|
||||
<view class="channel-video" :class="{ 'list-mode': listMode }">
|
||||
<!-- 嵌入式视频播放 -->
|
||||
<view v-if="canUseEmbedMode" class="embed-video-container">
|
||||
<view class="video-cover-wrap" :style="[coverStyle]">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<native-component>
|
||||
<!-- 嵌入式视频播放组件 -->
|
||||
<channel-video :feed-id="value.feedId" :finder-user-name="value.finderUserName"
|
||||
:feed-token="value.feedToken" :auto-play="autoPlay">
|
||||
</channel-video>
|
||||
</native-component>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<view class="video-info">
|
||||
<!-- 视频标题,支持多行显示控制 -->
|
||||
<view class="video-title" :style="[{ '--title-line-clamp': titleLineClamp }]">{{ value.videoTitle }}
|
||||
</view>
|
||||
<!-- 视频统计信息 -->
|
||||
<view class="video-stats" v-if="showViewCount && value.showViewCount">{{ $lang('components-diy.diy-channel-video.viewCountSuffix', { count: value.viewCount }) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 跳转式视频播放 -->
|
||||
<view v-else @tap.stop="playVideo" class="video-container">
|
||||
<view class="video-cover-wrap" :style="[coverStyle]">
|
||||
<image class="video-cover" :src="$util.img(value.coverUrl)" mode="aspectFill" show-menu-by-longpress></image>
|
||||
<view class="channel-play-btn" v-if="showPlayBtn" :style="[playBtnStyle]">
|
||||
<view class="play-icon-css"></view>
|
||||
</view>
|
||||
<view class="video-stats-overlay" v-if="showViewCount && value.showViewCount">
|
||||
{{ $lang('components-diy.diy-channel-video.viewCountSuffix', { count: value.viewCount }) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="video-info">
|
||||
<!-- 视频标题,支持多行显示控制 -->
|
||||
<view class="video-title" :style="[{ '--title-line-clamp': titleLineClamp }]">{{ value.videoTitle }}
|
||||
</view>
|
||||
<!-- 视频统计信息,非列表模式下显示 -->
|
||||
<view class="video-stats" v-if="showViewCount && value.showViewCount && !listMode">{{ $lang('components-diy.diy-channel-video.viewCountSuffix', { count: value.viewCount }) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { wechatChannelUtil, wechatChannelConfig } from './js/wechat-channel.js'
|
||||
|
||||
/**
|
||||
* 微信视频号视频卡片组件
|
||||
* 支持嵌入式播放和跳转式播放两种模式
|
||||
* 可配置列表模式、视频高度、标题行数等属性
|
||||
*/
|
||||
export default {
|
||||
name: 'diy-channel-video',
|
||||
props: {
|
||||
/**
|
||||
* 视频数据对象
|
||||
* @type {Object}
|
||||
* @required
|
||||
* @property {string} feedId - 视频 feedId
|
||||
* @property {string} feedToken - 视频 token
|
||||
* @property {string} coverUrl - 视频封面图
|
||||
* @property {string} videoTitle - 视频标题
|
||||
* @property {number} viewCount - 观看次数
|
||||
* @property {boolean} showViewCount - 是否显示观看次数
|
||||
* @property {boolean} embedMode - 是否启用嵌入式播放
|
||||
*/
|
||||
value: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* 是否为列表模式
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
*/
|
||||
listMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 是否显示观看次数,由父组件可以整体配置 */
|
||||
showViewCount: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 标题显示行数
|
||||
* @type {number}
|
||||
* @default 1
|
||||
*/
|
||||
titleLineClamp: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/** 是否显示播放按钮 */
|
||||
showPlayBtn: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 视频封面图样式
|
||||
* 采用 16:9 比例的响应式高度
|
||||
*/
|
||||
coverStyle: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
width: '100%',
|
||||
height: '0',
|
||||
paddingTop: '56.25%' // 16:9 比例
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 播放按钮样式
|
||||
*/
|
||||
playBtnStyle: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
width: '60rpx',
|
||||
height: '60rpx',
|
||||
borderRadius: '30rpx',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)'
|
||||
})
|
||||
},
|
||||
/** 是否自动播放 */
|
||||
autoPlay: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* 是否支持嵌入式播放
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canUseEmbedMode() {
|
||||
// #ifdef MP-WEIXIN
|
||||
const enableEmbedMode = Boolean(this.value?.embedMode)
|
||||
&& Boolean(this.value?.feedToken)
|
||||
&& wechatChannelUtil.isEmbedModeSupported();
|
||||
return enableEmbedMode;
|
||||
// #endif
|
||||
return false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 播放视频
|
||||
* 触发 video-play 事件,由父组件处理具体播放逻辑
|
||||
*/
|
||||
async playVideo() {
|
||||
this.$emit('video-play', this.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import './css/common-channel.scss';
|
||||
|
||||
/**
|
||||
* 视频卡片容器样式
|
||||
* 包含卡片基础样式、悬停效果和列表模式样式
|
||||
*/
|
||||
.channel-video {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
// 悬停效果
|
||||
&:hover {
|
||||
transform: translateY(-4rpx);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// 列表模式样式调整
|
||||
&.list-mode {
|
||||
border-radius: 10rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
.video-info {
|
||||
padding: 14rpx;
|
||||
|
||||
.video-title {
|
||||
font-size: 24rpx;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频容器样式
|
||||
* 用于跳转式播放模式
|
||||
*/
|
||||
.video-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌入式视频容器样式
|
||||
* 与视频容器样式保持一致
|
||||
*/
|
||||
.embed-video-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频封面容器
|
||||
*/
|
||||
.video-cover-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%;
|
||||
/* 16:9 比例 */
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx 12rpx 0 0;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
// 悬停时缩放效果
|
||||
.channel-video:hover & {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
// 列表模式下的边框圆角调整
|
||||
.channel-video.list-mode & {
|
||||
border-radius: 10rpx 10rpx 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频封面图片
|
||||
* 绝对定位填充整个容器
|
||||
*/
|
||||
.video-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频统计信息遮罩
|
||||
* 显示在视频封面底部
|
||||
*/
|
||||
.video-stats-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: 12rpx 16rpx;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
// 悬停时的透明度变化
|
||||
.channel-video:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.channel-video:not(:hover) & {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频信息区域
|
||||
* 包含标题和统计信息
|
||||
*/
|
||||
.video-info {
|
||||
padding: 16rpx;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background-color: #fff;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
// 悬停时的背景色变化
|
||||
.channel-video:hover & {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频标题
|
||||
* 支持多行显示控制和渐变遮罩效果
|
||||
*/
|
||||
.video-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin-bottom: 10rpx;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: var(--title-line-clamp, 2);
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.3s ease;
|
||||
position: relative;
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
|
||||
// 悬停时的颜色变化
|
||||
.channel-video:hover & {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
// 列表模式下的样式调整
|
||||
.channel-video.list-mode & {
|
||||
-webkit-line-clamp: var(--title-line-clamp, 1);
|
||||
font-size: 24rpx;
|
||||
margin-bottom: 6rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// 添加渐变遮罩效果,让过长的文字有柔和的结束
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 40rpx;
|
||||
height: 1.4em;
|
||||
background: linear-gradient(to right, transparent, #fff 90%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 列表模式下的遮罩高度调整
|
||||
.channel-video.list-mode &::after {
|
||||
height: 1.3em;
|
||||
}
|
||||
|
||||
// 根据行数调整遮罩高度
|
||||
&[style*="--title-line-clamp: 1"]::after {
|
||||
height: 1.4em;
|
||||
}
|
||||
|
||||
&[style*="--title-line-clamp: 3"]::after {
|
||||
height: 4.2em;
|
||||
}
|
||||
|
||||
&[style*="--title-line-clamp: 4"]::after {
|
||||
height: 5.6em;
|
||||
}
|
||||
|
||||
// 列表模式下的多行遮罩高度调整
|
||||
.channel-video.list-mode &[style*="--title-line-clamp: 1"]::after {
|
||||
height: 1.3em;
|
||||
}
|
||||
|
||||
.channel-video.list-mode &[style*="--title-line-clamp: 3"]::after {
|
||||
height: 3.9em;
|
||||
}
|
||||
|
||||
.channel-video.list-mode &[style*="--title-line-clamp: 4"]::after {
|
||||
height: 5.2em;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频统计信息
|
||||
* 显示观看次数等数据
|
||||
*/
|
||||
.video-stats {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 6rpx;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
// 悬停时的颜色变化
|
||||
.channel-video:hover & {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表模式下的播放按钮样式
|
||||
* 更小的尺寸和悬停效果
|
||||
*/
|
||||
.channel-video.list-mode .channel-play-btn {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
// 悬停效果
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
// 播放图标尺寸
|
||||
.play-icon {
|
||||
width: 25rpx;
|
||||
height: 25rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用播放按钮样式优化
|
||||
* 添加悬停效果
|
||||
*/
|
||||
.channel-play-btn {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
// 悬停效果
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯 CSS 播放按钮图标
|
||||
*/
|
||||
.play-icon-css {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 12rpx solid transparent;
|
||||
border-bottom: 12rpx solid transparent;
|
||||
border-left: 18rpx solid #fff;
|
||||
margin-left: 4rpx;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
// 悬停时的缩放效果
|
||||
.channel-play-btn:hover & {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
// 列表模式下的尺寸调整
|
||||
.channel-video.list-mode & {
|
||||
border-top: 10rpx solid transparent;
|
||||
border-bottom: 10rpx solid transparent;
|
||||
border-left: 15rpx solid #fff;
|
||||
margin-left: 3rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生组件样式控制
|
||||
* 确保 native-component 和 channel-video 正确填充容器
|
||||
*/
|
||||
native-component {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* channel-video 组件样式控制
|
||||
* 避免受到 wx-channel-video 全局样式的影响
|
||||
*/
|
||||
channel-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-float-btn" class="float-btn"
|
||||
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
|
||||
:style="style">
|
||||
<block v-for="(item, index) in value.list" :key="index">
|
||||
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)"
|
||||
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
|
||||
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
|
||||
show-menu-by-longpress />
|
||||
<diy-icon v-else-if="item.iconType && item.iconType == 'icon'" :icon="item.icon"
|
||||
:value="item.style ? item.style : null"></diy-icon>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义浮动按钮
|
||||
import DiyMinx from './minx.js'
|
||||
// 获取系统状态栏的高度
|
||||
let systemInfo = uni.getSystemInfoSync();
|
||||
|
||||
// 自定义浮动按钮
|
||||
export default {
|
||||
name: 'diy-float-btn',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
navHeight: 0,
|
||||
statusBarHeight: systemInfo.statusBarHeight
|
||||
};
|
||||
},
|
||||
created() { },
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
components: {},
|
||||
methods: {},
|
||||
computed: {
|
||||
style() {
|
||||
let style = {},
|
||||
height = 54;
|
||||
// #ifdef MP
|
||||
height = systemInfo.platform == 'ios' ? 54 : 58;
|
||||
// #endif
|
||||
switch (parseInt(this.value.bottomPosition)) {
|
||||
case 1:
|
||||
style.top = (this.navHeight + this.statusBarHeight + parseInt(this.value.btnBottom)) * 2 + 'rpx';
|
||||
break;
|
||||
case 2:
|
||||
style.top = (this.navHeight + this.statusBarHeight + parseInt(this.value.btnBottom)) * 2 + 'rpx';
|
||||
break;
|
||||
case 3:
|
||||
style.bottom = (100 + parseInt(this.value.btnBottom)) * 2 + 'rpx';
|
||||
break;
|
||||
case 4:
|
||||
style.bottom = (100 + parseInt(this.value.btnBottom)) * 2 + 'rpx';
|
||||
break;
|
||||
}
|
||||
return this.$util.objToStyle(style);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.float-btn {
|
||||
position: fixed;
|
||||
bottom: 20%;
|
||||
right: 40rpx;
|
||||
z-index: 990;
|
||||
|
||||
&.left_top {
|
||||
top: 100rpx;
|
||||
left: 30rpx;
|
||||
}
|
||||
|
||||
&.right_top {
|
||||
top: 100rpx;
|
||||
right: 30rpx;
|
||||
}
|
||||
|
||||
&.left_bottom {
|
||||
bottom: 160rpx;
|
||||
left: 30rpx;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
/*兼容 IOS<11.2*/
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/*兼容 IOS>11.2*/
|
||||
}
|
||||
|
||||
&.right_bottom {
|
||||
bottom: 160rpx;
|
||||
right: 30rpx;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
/*兼容 IOS<11.2*/
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
/*兼容 IOS>11.2*/
|
||||
}
|
||||
|
||||
.button-box {
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<!-- #ifdef MP -->
|
||||
<view data-component-name="diy-follow-official-account" v-if="value.isShow">
|
||||
<official-account></official-account>
|
||||
</view>
|
||||
<!--#endif -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 关注公众号展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-follow-official-account',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
methods: {}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,384 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
|
||||
<!-- Banner -->
|
||||
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
|
||||
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
|
||||
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
|
||||
r=function(){var o=this.value.show_banner...this.$mp.data...})
|
||||
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
|
||||
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
|
||||
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
|
||||
</view>
|
||||
<!-- 提示语 -->
|
||||
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
|
||||
<!-- 表单组件 -->
|
||||
<ns-newform
|
||||
:data="formComponents"
|
||||
:customAttr="value"
|
||||
:mode="mergedFormConfig.formLayout || 'horizontal'"
|
||||
:formConfig="mergedFormConfig"
|
||||
ref="nsNewform"
|
||||
></ns-newform>
|
||||
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
|
||||
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
|
||||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义表单
|
||||
import DiyMinx from './minx.js'
|
||||
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
||||
|
||||
export default {
|
||||
name: 'diy-from',
|
||||
components: { NsNewform },
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
},
|
||||
// 数据源模式:
|
||||
// 'diy'(默认):value.formComponents 是 [{ type, value }],组件内部转换为 ns-newform 期望的 [{ controller, value, val }]
|
||||
// 'raw' :value.jsonData / value.json_data 已经是 ns-newform 期望的格式,直接透传给 ns-newform
|
||||
// 用途:让 formdata 等"已是 ns-newform 格式"的页面也能复用本组件的 banner/tip/样式系统
|
||||
dataMode: {
|
||||
type: String,
|
||||
default: 'diy',
|
||||
validator: (v) => ['diy', 'raw'].includes(v)
|
||||
},
|
||||
// 是否渲染内置提交按钮。false 时由父级调用 ref 上的 verify()/getFormData() 自行实现提交
|
||||
showSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
formComponents: [] // 转换后的表单组件
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.initForm();
|
||||
},
|
||||
watch: {
|
||||
'value': {
|
||||
deep: true,
|
||||
handler() {
|
||||
// raw 模式:formData 由父级透传,跳过重跑 initForm(避免每次父组件 re-render 触发 ns-newform 的深拷贝 initFormData)
|
||||
if (this.dataMode === 'raw') return;
|
||||
this.initForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
|
||||
var css = '';
|
||||
var v = this.value || {};
|
||||
css += 'background-color:' + (v.contentBgColor || '') + ';';
|
||||
if (v.elementAngle === 'round') {
|
||||
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
},
|
||||
// 兼容旧 style 别名(部分页面用 :style="style" 引用)
|
||||
diyFormStyle() {
|
||||
return this.style;
|
||||
},
|
||||
// Banner 容器左右内边距(与后端 .form-preview-banner padding-x 对齐)
|
||||
bannerStyle() {
|
||||
return this.$util.objToStyle({
|
||||
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
|
||||
});
|
||||
},
|
||||
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
|
||||
tipStyle() {
|
||||
const cfg = this.mergedFormConfig || {};
|
||||
const style = {
|
||||
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.tipPaddingX, 14)}rpx`,
|
||||
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
|
||||
'color': cfg.tipColor || '#000000',
|
||||
'background-color': cfg.tipBgColor || 'transparent'
|
||||
};
|
||||
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
|
||||
if (cfg.tipBorderColor) {
|
||||
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
|
||||
}
|
||||
return this.$util.objToStyle(style);
|
||||
},
|
||||
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
|
||||
submitWrapStyle() {
|
||||
const cfg = this.mergedFormConfig || {};
|
||||
return this.$util.objToStyle({
|
||||
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
|
||||
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
|
||||
'background-color': cfg.formBgColor || '#ffffff'
|
||||
});
|
||||
},
|
||||
// 提交按钮样式:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
|
||||
// 与后端 design.css .form-preview-submit button 对齐:
|
||||
// - 高度由 --submit-padding-y + 字号决定,避免硬编码 line-height
|
||||
// - 宽度由 --submit-width-percent 控制(10-100,clamp)
|
||||
submitStyle() {
|
||||
const cfg = this.mergedFormConfig || {};
|
||||
// 宽度百分比:clamp 到 10-100
|
||||
const widthPct = Math.min(100, Math.max(10, this._num(cfg.submitWidthPercent, 100)));
|
||||
// 上下内边距:与后端 design.js submitSizeMap 对齐(mini/small/medium/large/huge → 0/4/12/24/40 rpx)
|
||||
const submitPaddingMap = { mini: 0, small: 4, medium: 12, large: 24, huge: 40 };
|
||||
const submitPaddingY = submitPaddingMap[cfg.submitSize] != null
|
||||
? submitPaddingMap[cfg.submitSize]
|
||||
: submitPaddingMap.medium;
|
||||
return this.$util.objToStyle({
|
||||
width: widthPct + '%',
|
||||
padding: `${submitPaddingY}rpx 0`,
|
||||
color: cfg.submitColor || '#ffffff',
|
||||
'background-color': cfg.submitBgColor || '#ff4544',
|
||||
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
|
||||
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
|
||||
});
|
||||
},
|
||||
// 提交按钮文字:与后端 design.html <button>提交</button> 默认对齐,开放给 formConfig 覆盖
|
||||
// 走 mergedFormConfig(兼容 camelCase / snake_case),让 formConfig.submitText 能正确覆盖
|
||||
submitText() {
|
||||
return this.mergedFormConfig.submitText || '提交';
|
||||
},
|
||||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||||
formCfg() {
|
||||
const v = this.value || {};
|
||||
if (v.formConfig && typeof v.formConfig === 'object') {
|
||||
return v.formConfig;
|
||||
}
|
||||
return v;
|
||||
},
|
||||
// 合并后的表单配置(支持平台差异化)
|
||||
mergedFormConfig() {
|
||||
const v = this.value || {};
|
||||
const fc = this.formCfg;
|
||||
// 后端实际返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
|
||||
// 但旧版 design.js / 部分序列化路径可能用 snake_case(form_padding_x)。这里同时读两种写法,camelCase 优先
|
||||
const self = this;
|
||||
const pick = function(camel, snake) {
|
||||
var v1 = fc[camel];
|
||||
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
|
||||
return fc[snake];
|
||||
};
|
||||
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
|
||||
const base = {
|
||||
formPaddingX: pick('formPaddingX', 'form_padding_x'),
|
||||
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
|
||||
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
|
||||
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
|
||||
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
|
||||
labelGap: pick('labelGap', 'label_gap'),
|
||||
formBgColor: pick('formBgColor', 'form_bg_color'),
|
||||
rowDividerColor: pick('rowDividerColor', 'row_divider_color'),
|
||||
showRowDivider: self._coerceBool(pick('showRowDivider', 'show_row_divider')),
|
||||
inputHeight: pick('inputHeight', 'input_height'),
|
||||
inputRadius: pick('inputRadius', 'input_radius'),
|
||||
formLayout: pick('formLayout', 'form_layout'),
|
||||
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
|
||||
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
|
||||
labelFontSize: pick('labelFontSize', 'label_font_size'),
|
||||
labelColor: pick('labelColor', 'label_color'),
|
||||
requiredColor: pick('requiredColor', 'required_color'),
|
||||
checkedColor: pick('checkedColor', 'checked_color'),
|
||||
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
submitText: pick('submitText', 'submit_text'),
|
||||
submitColor: pick('submitColor', 'submit_color'),
|
||||
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
|
||||
// 提交按钮尺寸:宽度百分比 + 尺寸预设(与后端 design.js 对齐)
|
||||
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
|
||||
submitSize: pick('submitSize', 'submit_size'),
|
||||
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
|
||||
// 提示语
|
||||
tipFontSize: pick('tipFontSize', 'tip_font_size'),
|
||||
tipColor: pick('tipColor', 'tip_color'),
|
||||
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
|
||||
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
|
||||
};
|
||||
// 移除 undefined / 空字符串(保留 0/false)
|
||||
Object.keys(base).forEach(function(k) {
|
||||
if (base[k] === undefined || base[k] === null || base[k] === '') {
|
||||
delete base[k];
|
||||
}
|
||||
});
|
||||
// 平台差异化配置(terminal 块在 formConfig 子对象内)
|
||||
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
|
||||
const result = Object.assign({}, base);
|
||||
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
|
||||
if (termBlock && typeof termBlock === 'object') {
|
||||
Object.keys(termBlock).forEach(function(platform) {
|
||||
var src = termBlock[platform];
|
||||
if (!src) return;
|
||||
// 嵌套值若为 snake_case(部分序列化路径),在此统一转 camelCase
|
||||
var translated = {};
|
||||
Object.keys(src).forEach(function(k) {
|
||||
translated[self.$util.snakeToCamel(k)] = src[k];
|
||||
});
|
||||
result[platform] = translated;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 初始化表单 - 将 diy-form 的数据格式转换成 ns-newform 的格式
|
||||
initForm() {
|
||||
if (!this.value) {
|
||||
this.formComponents = [];
|
||||
return;
|
||||
}
|
||||
// raw 模式:value.jsonData / value.json_data 已经是 ns-newform 期望的 [{ controller, value, val }],直接透传
|
||||
if (this.dataMode === 'raw') {
|
||||
this.formComponents = this.value.jsonData || this.value.json_data || [];
|
||||
return;
|
||||
}
|
||||
var components = this.value.formComponents || this.value.form_components || [];
|
||||
// 转换格式:type -> controller
|
||||
this.formComponents = (components || []).map(function(item) {
|
||||
return {
|
||||
controller: item.type,
|
||||
value: item.value || {},
|
||||
val: (item.value && item.value.default) ? item.value.default : '' // 添加初始值
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// 暴露给父级:触发 ns-newform 校验。校验失败时 ns-newform 内部会 toast;返回 false 表示失败,返回 formData 数组表示成功
|
||||
verify() {
|
||||
if (this.$refs.nsNewform && this.$refs.nsNewform.verify) {
|
||||
return this.$refs.nsNewform.verify();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
// 暴露给父级:获取 ns-newform 当前的 formData 数组(含 val / option_lists / img_lists 等全部字段)
|
||||
getFormData() {
|
||||
return (this.$refs.nsNewform && this.$refs.nsNewform.formData) || [];
|
||||
},
|
||||
|
||||
// 数字保底:undefined/空串/非数字时返回兜底值
|
||||
_num(v, fb) {
|
||||
if (v === '' || v === null || v === undefined) return fb;
|
||||
var n = parseInt(v);
|
||||
return isNaN(n) ? fb : n;
|
||||
},
|
||||
// 布尔保底:0/false/'0'/'false' → false;其它有值 → true
|
||||
_coerceBool(v) {
|
||||
if (v === false || v === 0 || v === '0' || v === 'false' || v === null || v === undefined) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
submitForm() {
|
||||
var self = this;
|
||||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||||
if (!this.verify()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
|
||||
if (this.$listeners && this.$listeners.submit) {
|
||||
this.$emit('submit', this.getFormData());
|
||||
return;
|
||||
}
|
||||
|
||||
// 获得保单数据
|
||||
const submitFormData = this.getFormData();
|
||||
|
||||
// 提交到后端
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
form_id: this.value.form_id || 0,
|
||||
form_data: typeof submitFormData === 'string' ? submitFormData : JSON.stringify(submitFormData),
|
||||
component_id: this.value.id || '',
|
||||
form_name: this.value.form_name || '默认表单'
|
||||
},
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
uni.showToast({
|
||||
title: '提交成功',
|
||||
icon: 'success'
|
||||
});
|
||||
// 重置表单
|
||||
if (self.$refs.nsNewform && self.$refs.nsNewform.setFormData) {
|
||||
self.$refs.nsNewform.setFormData();
|
||||
}
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.message || '提交失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.diy-from {
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
border-radius: 10rpx;
|
||||
|
||||
.form-banner {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
|
||||
margin: 0;
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-submit-wrap {
|
||||
// 外层:与后端 .form-preview-submit 对齐(margin-top + padding-y + form-padding-x + form-bg-color + flex 居中)
|
||||
// margin-top/padding/background 由 submitWrapStyle 内联写入
|
||||
// display: flex + justify-content: center 是让宽度小于 100% 的按钮水平居中的关键
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
// 提交按钮:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
|
||||
// 全部由 submitStyle 内联写入
|
||||
// 宽度不再写死 100%,由内联 style 的 width: <pct>% 决定;max-width: 100% 防止溢出
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1rpx;
|
||||
transition: opacity 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,166 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-goods-brand" type="waterfall" :loading="loading" :configs="skeletonConfig">
|
||||
<view :class="['brand-wrap', value.ornament.type]" :style="warpCss">
|
||||
<view :class="[value.style]">
|
||||
<view class="title-wrap" v-show="value.title"
|
||||
:style="{ color: value.textColor, fontWeight: value.fontWeight ? 'bold' : '' }">{{ value.title }}
|
||||
</view>
|
||||
<view class="ul-wrap">
|
||||
<view class="li-item" v-for="(item, index) in list" :key="index">
|
||||
<image class="brand-pic" :src="$util.img(item.image_url)" mode="aspectFit"
|
||||
@tap.stop="handlerClick(item)" @error="imgError(index)"
|
||||
:style="itemCss" show-menu-by-longpress/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 商品品牌
|
||||
|
||||
import DiyMinx from './minx.js'
|
||||
// 自定义商品品牌展示
|
||||
export default {
|
||||
name: 'diy-goods-brand',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
list: [],
|
||||
loading: true,
|
||||
skeletonConfig: {
|
||||
gridRows: 2,
|
||||
gridColumns: 4,
|
||||
gridRowsGap: '20rpx',
|
||||
headWidth: '120rpx',
|
||||
headHeight: '120rpx',
|
||||
textShow: false
|
||||
}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getBrandList();
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getBrandList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.value.ornament.type == 'shadow') {
|
||||
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color;
|
||||
}
|
||||
if (this.value.ornament.type == 'stroke') {
|
||||
obj += 'border:' + '2rpx solid ' + this.value.ornament.color;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 子项样式
|
||||
itemCss() {
|
||||
var obj = '';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getBrandList() {
|
||||
var data = {
|
||||
page: 1,
|
||||
page_size: this.value.count
|
||||
};
|
||||
if (this.value.sources == 'diy') {
|
||||
data.page_size = 0;
|
||||
data.brand_id_arr = this.value.brandIds.toString();
|
||||
}
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goodsbrand/page',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
let data = res.data;
|
||||
this.list = data.list;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
toDetail(item) {
|
||||
this.$util.redirectTo('/pages_goods/list', {
|
||||
brand_id: item.brand_id
|
||||
});
|
||||
},
|
||||
imgError(index) {
|
||||
if (this.list[index]) this.list[index].image_url = this.$util.getDefaultImage().goods;
|
||||
},
|
||||
async handlerClick(item) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'goods-brand-tap', data: item, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.toDetail(item);
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.brand-wrap {
|
||||
&.shadow {
|
||||
margin-left: 8rpx;
|
||||
margin-right: 8rpx;
|
||||
margin-top: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.style-1 {
|
||||
.title-wrap {
|
||||
text-align: center;
|
||||
padding: 20rpx 0 10rpx;
|
||||
}
|
||||
|
||||
.ul-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 20rpx;
|
||||
|
||||
.li-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(100% / 4 - 20rpx) !important;
|
||||
height: 124rpx;
|
||||
margin: 10rpx;
|
||||
background-color: #fff;
|
||||
|
||||
.brand-pic {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,626 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-goods-recommend" type="waterfall" :loading="loading" :configs="skeletonConfig">
|
||||
<view v-if="list.length" :class="['goods-list', goodsValue.style]" :style="goodsListWarpCss">
|
||||
<view class="top-wrap" v-if="goodsValue.topStyle.support">
|
||||
<text :class="['js-icon', goodsValue.topStyle.icon.value]"
|
||||
:style="{ backgroundColor: goodsValue.topStyle.icon.bgColor, color: goodsValue.topStyle.icon.color }"></text>
|
||||
<text class="title" :style="{ color: goodsValue.topStyle.color }">{{ goodsValue.topStyle.title }}</text>
|
||||
<text class="line" :style="{ color: goodsValue.topStyle.subColor }"></text>
|
||||
<text class="sub" :style="{ color: goodsValue.topStyle.subColor }">{{ goodsValue.topStyle.subTitle
|
||||
}}</text>
|
||||
</view>
|
||||
<swiper :autoplay="false" class="swiper" :style="{ height: swiperHeight }">
|
||||
<swiper-item v-for="(item, index) in page" :key="index"
|
||||
:class="['swiper-item', [list[index].length / 3] >= 1 && 'flex-between']">
|
||||
<view class="goods-item" v-for="(dataItem, dataIndex) in list[index]" :key="dataIndex"
|
||||
@tap.stop="toDetail(dataItem)" :class="[goodsValue.ornament.type]" :style="goodsItemCss">
|
||||
<div class="goods-img-wrap">
|
||||
<image class="goods-img" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(dataItem.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imgError(index, dataIndex)" :lazy-load="true" show-menu-by-longpress />
|
||||
<view class="sell-out" v-if="dataItem.stock <= 0">
|
||||
<text class="iconfont icon-shuqing"></text>
|
||||
</view>
|
||||
</div>
|
||||
<view :class="['info-wrap', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="goodsValue.goodsNameStyle.control || goodsValue.priceStyle.mainControl || goodsValue.priceStyle.lineControl || goodsValue.labelStyle.support">
|
||||
<view v-if="goodsValue.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.goodsNameStyle.color : '', fontWeight: goodsValue.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': goodsValue.nameLineMode == 'single' }, { 'multi-hidden': goodsValue.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? dataItem.en_goods_name : dataItem.goods_name }}
|
||||
</view>
|
||||
<view class="pro-info">
|
||||
<view class="label-wrap" v-if="goodsValue.labelStyle.support"
|
||||
:style="{ background: goodsValue.labelStyle.bgColor, color: goodsValue.labelStyle.color }">
|
||||
<image :src="$util.img('app/component/view/goods_recommend/img/label.png')"
|
||||
mode="widthFix" show-menu-by-longpress />
|
||||
<text>{{ goodsValue.labelStyle.title }}</text>
|
||||
</view>
|
||||
<view class="discount-price">
|
||||
<view class="price-wrap" v-if="goodsValue.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.mainColor + '!important' : '' }">{{
|
||||
showPrice(dataItem).split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.mainColor + '!important' : '' }">{{
|
||||
'.' + showPrice(dataItem).split('.')[1] }}</text>
|
||||
</view>
|
||||
<view v-if="goodsValue.priceStyle.lineControl && showMarketPrice(dataItem)"
|
||||
class="delete-price price-font"
|
||||
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.lineColor : '' }">
|
||||
¥{{ showMarketPrice(dataItem) }}</view>
|
||||
<view class="sale" v-if="goodsValue.saleStyle.control"
|
||||
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.saleStyle.color : '' }">
|
||||
{{ $lang('components-diy.diy-goods-recommend.soldPrefix') }}{{ dataItem.sale_num }}{{ dataItem.unit ? dataItem.unit : $lang('unit.piece') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义商品推荐展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-goods-recommend',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
skeletonConfig: {
|
||||
gridRows: 1,
|
||||
gridColumns: 3,
|
||||
headWidth: '200rpx',
|
||||
headHeight: '200rpx',
|
||||
textRows: 2,
|
||||
textWidth: ['100%', '60%'],
|
||||
},
|
||||
list: [],
|
||||
goodsValue: {},
|
||||
page: 1
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.goodsValue = this.value;
|
||||
this.getGoodsList();
|
||||
},
|
||||
watch: {
|
||||
'globalStoreInfo.store_id': {
|
||||
handler(nval, oval) {
|
||||
if (nval != oval) {
|
||||
this.getGoodsList();
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getGoodsList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
goodsListWarpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.goodsValue.componentBgColor + ';';
|
||||
if (this.goodsValue.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.goodsValue.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.goodsValue.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.goodsValue.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.goodsValue.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.goodsValue.bgUrl) {
|
||||
obj += `background-image: url('${this.$util.img(this.goodsValue.bgUrl)}');`;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 商品项样式
|
||||
goodsItemCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.goodsValue.elementAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.goodsValue.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.goodsValue.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.goodsValue.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.goodsValue.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.goodsValue.ornament.type == 'shadow') {
|
||||
obj += 'box-shadow:' + '0 0 10rpx ' + this.goodsValue.ornament.color + ';';
|
||||
}
|
||||
if (this.goodsValue.ornament.type == 'stroke') {
|
||||
obj += 'border:' + '2rpx solid ' + this.goodsValue.ornament.color + ';';
|
||||
}
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
var width = '';
|
||||
if (this.goodsValue.style != 'style-2') {
|
||||
width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this.rpxUpPx(this.value.margin
|
||||
.both * 2) * 2] / 6;
|
||||
} else {
|
||||
width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this
|
||||
.rpxUpPx(this.value.margin.both * 2) * 2
|
||||
] / 6;
|
||||
}
|
||||
obj += 'margin-left:' + width + 'px;';
|
||||
obj += 'margin-right:' + width + 'px;';
|
||||
return obj;
|
||||
},
|
||||
swiperHeight() {
|
||||
if (this.goodsValue.style == 'style-3') {
|
||||
return '330rpx';
|
||||
} else if (this.goodsValue.style != 'style-2') {
|
||||
if (this.value.nameLineMode == 'multiple') {
|
||||
return '348rpx';
|
||||
}
|
||||
return '312rpx';
|
||||
} else {
|
||||
if (this.value.nameLineMode == 'multiple') {
|
||||
return '360rpx';
|
||||
}
|
||||
return '320rpx';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
rpxUpPx(res) {
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
var data = (screenWidth * parseInt(res)) / 750;
|
||||
return Math.floor(data);
|
||||
},
|
||||
getGoodsList() {
|
||||
var data = {
|
||||
num: this.goodsValue.count
|
||||
};
|
||||
if (this.goodsValue.sources == 'category') {
|
||||
data.category_id = this.goodsValue.categoryId;
|
||||
data.category_level = 1;
|
||||
} else if (this.goodsValue.sources == 'diy') {
|
||||
data.num = 0;
|
||||
data.goods_id_arr = this.goodsValue.goodsId.toString();
|
||||
}
|
||||
data.order = this.goodsValue.sortWay;
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goodssku/components',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
let data = res.data;
|
||||
this.list = data;
|
||||
|
||||
// 切屏滚动,每页显示固定数量
|
||||
let size = 3;
|
||||
let temp = [];
|
||||
this.page = Math.ceil(this.list.length / size);
|
||||
for (var i = 0; i < this.page; i++) {
|
||||
temp[i] = [];
|
||||
for (var j = i * size; j < this.list.length; j++) {
|
||||
if (temp[i].length == size) break;
|
||||
temp[i].push(this.list[j]);
|
||||
}
|
||||
}
|
||||
this.list = temp;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
toDetail(item) {
|
||||
this.$util.redirectTo('/pages_goods/detail', {
|
||||
goods_id: item.goods_id
|
||||
});
|
||||
},
|
||||
imgError(pageIndex, index) {
|
||||
this.list[pageIndex][index].goods_image = this.$util.getDefaultImage().goods;
|
||||
},
|
||||
showPrice(data) {
|
||||
let price = data.discount_price;
|
||||
if (data.member_price && parseFloat(data.member_price) < parseFloat(price)) price = data.member_price;
|
||||
return price;
|
||||
},
|
||||
showMarketPrice(item) {
|
||||
let price = this.showPrice(item);
|
||||
if (item.market_price > 0) {
|
||||
return item.market_price;
|
||||
} else if (item.price > price) {
|
||||
return item.price;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.goods-list {
|
||||
.goods-item {
|
||||
line-height: 1;
|
||||
|
||||
.sale {
|
||||
line-height: 1;
|
||||
color: $color-tip;
|
||||
font-size: $font-size-activity-tag;
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
.goods-name {
|
||||
margin-bottom: 10rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.sell-out {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
text {
|
||||
color: #fff;
|
||||
font-size: 180rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品列表横向滚动样式
|
||||
.goods-list.style-1 {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
background-repeat: round;
|
||||
|
||||
.top-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
|
||||
.js-icon {
|
||||
border-radius: 50%;
|
||||
font-size: 40rpx;
|
||||
margin-right: 10rpx;
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
text-align: center;
|
||||
line-height: 70rpx;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 28rpx;
|
||||
margin: 0 10rpx;
|
||||
border: 2rpx solid;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.swiper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 20rpx;
|
||||
|
||||
.swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
overflow: hidden;
|
||||
width: 200rpx;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(3n + 3) {
|
||||
width: 198rpx;
|
||||
}
|
||||
|
||||
&.shadow {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.goods-img,
|
||||
.goods-img-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 196rpx;
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10rpx;
|
||||
|
||||
&.multi-content {
|
||||
height: 130rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
font-size: $font-size-sub;
|
||||
|
||||
&.multi-hidden {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
|
||||
.pro-info {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.discount-price {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.price-wrap {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
|
||||
text {
|
||||
font-weight: bold;
|
||||
color: $base-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-price {
|
||||
margin-left: 10rpx;
|
||||
text-decoration: line-through;
|
||||
flex: 1;
|
||||
line-height: 28rpx;
|
||||
color: $color-tip;
|
||||
font-size: $font-size-activity-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 商品列表横向滚动样式
|
||||
.goods-list.style-2 {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
background-repeat: round;
|
||||
padding-bottom: 20rpx;
|
||||
|
||||
.top-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
|
||||
.js-icon {
|
||||
border-radius: 50%;
|
||||
font-size: 40rpx;
|
||||
margin-right: 20rpx;
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
text-align: center;
|
||||
line-height: 70rpx;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 28rpx;
|
||||
margin: 0 10rpx;
|
||||
border: 2rpx solid;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
}
|
||||
|
||||
.swiper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 20rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
overflow: hidden;
|
||||
width: 200rpx;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.shadow {
|
||||
margin-top: 8rpx;
|
||||
width: 200rpx;
|
||||
}
|
||||
|
||||
.goods-img,
|
||||
.goods-img-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
padding: 10rpx;
|
||||
|
||||
.goods-name {
|
||||
line-height: 1;
|
||||
|
||||
&.multi-hidden {
|
||||
line-height: 1.3;
|
||||
height: 68rpx;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
|
||||
.pro-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.discount-price {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.price-wrap {
|
||||
line-height: 1.3;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
text {
|
||||
font-weight: bold;
|
||||
color: $base-color;
|
||||
|
||||
&:last-of-type {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-price {
|
||||
margin-left: 10rpx;
|
||||
text-decoration: line-through;
|
||||
flex: 1;
|
||||
line-height: 28rpx;
|
||||
color: $color-tip;
|
||||
font-size: $font-size-activity-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-list.style-3 {
|
||||
background-position: bottom;
|
||||
|
||||
.swiper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 20rpx;
|
||||
padding: 10rpx 0;
|
||||
|
||||
.swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
overflow: hidden;
|
||||
width: 200rpx;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.shadow {
|
||||
// margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.goods-img,
|
||||
.goods-img-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10rpx;
|
||||
|
||||
.pro-info {
|
||||
text-align: center;
|
||||
|
||||
.label-wrap {
|
||||
border-radius: 40rpx;
|
||||
display: inline-block;
|
||||
margin: 10rpx 0;
|
||||
position: relative;
|
||||
padding-left: 52rpx;
|
||||
padding-right: 16rpx;
|
||||
line-height: 1.7;
|
||||
|
||||
image {
|
||||
position: absolute;
|
||||
top: -2rpx;
|
||||
left: -2rpx;
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
}
|
||||
|
||||
.discount-price {
|
||||
.price-wrap {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
|
||||
text {
|
||||
font-weight: bold;
|
||||
color: $base-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,317 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-graphic-nav" :style="componentStyle">
|
||||
<block v-if="value.showStyle == 'pageSlide'">
|
||||
<swiper :class="['graphic-nav', 'pageSlide', value.carousel.type]" circular :indicator-dots="false"
|
||||
:style="swiperHeight" @change="swiperChange">
|
||||
<swiper-item class="graphic-nav-wrap"
|
||||
v-for="(numItem, numIndex) in Math.ceil(value.list.length / (value.pageCount * value.rowCount))">
|
||||
<!-- #ifdef MP -->
|
||||
<view class="graphic-nav-item" :class="[value.mode]" v-for="(item, index) in value.list"
|
||||
:key="index"
|
||||
v-if="index >= [(numItem) * (value.pageCount * value.rowCount)] && index < [(numItem + 1) * (value.pageCount * value.rowCount)]"
|
||||
:style="{ width: 100 / value.rowCount + '%' }" @tap.stop="redirectTo(item.link)">
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="graphic-nav-item" :class="[value.mode]" v-for="(item, index) in value.list"
|
||||
:key="index"
|
||||
v-if="index >= [(numItem - 1) * (value.pageCount * value.rowCount)] && index < [numItem * (value.pageCount * value.rowCount)]"
|
||||
:style="{ width: 100 / value.rowCount + '%' }" @tap.stop="redirectTo(item.link)">
|
||||
<!-- #endif -->
|
||||
<view class="graphic-img" v-if="value.mode != 'text'"
|
||||
:style="{ fontSize: value.imageSize * 2 + 'rpx', width: value.imageSize * 2 + 'rpx', height: value.imageSize * 2 + 'rpx' }">
|
||||
<image v-if="item.iconType == 'img'"
|
||||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
mode="aspectFill"
|
||||
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', borderRadius: value.aroundRadius * 2 + 'rpx' }"
|
||||
show-menu-by-longpress />
|
||||
|
||||
<diy-icon v-if="item.iconType == 'icon'" :icon="item.icon"
|
||||
:value="item.style ? item.style : null"
|
||||
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
|
||||
|
||||
<text class="tag" v-if="item.label.control"
|
||||
:style="{ color: item.label.textColor, backgroundImage: 'linear-gradient(' + item.label.bgColorStart + ',' + item.label.bgColorEnd + ')' }">
|
||||
{{ item.label.text }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="value.mode != 'img'" class="graphic-text"
|
||||
:style="{ fontSize: value.font.size * 2 + 'rpx', fontWeight: value.font.weight, color: value.font.color }">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
<!-- #ifdef H5 -->
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP -->
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<view class="swiper-dot-box" v-if="isIndicatorDots" :class="value.carousel.type">
|
||||
<view v-for="(numItem, numIndex) in Math.ceil(value.list.length / (value.pageCount * value.rowCount))"
|
||||
:key="numIndex">
|
||||
<view class="swiper-dot" :class="{ 'active': numIndex == swiperCurrent }"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</block>
|
||||
<scroll-view v-else :scroll-x="value.showStyle == 'singleSlide'"
|
||||
:class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]">
|
||||
<!-- #ifdef MP -->
|
||||
<view class="uni-scroll-view-content">
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="graphic-nav-item" :class="[value.mode]" v-for="(item, index) in value.list" :key="index"
|
||||
:style="{ width: 100 / value.rowCount + '%' }" @tap.stop="redirectTo(item.link)">
|
||||
<view class="graphic-img" v-if="value.mode != 'text'"
|
||||
:style="{ fontSize: value.imageSize * 2 + 'rpx', width: value.imageSize * 2 + 'rpx', height: value.imageSize * 2 + 'rpx' }">
|
||||
<image v-if="item.iconType == 'img'"
|
||||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
mode="aspectFill"
|
||||
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', borderRadius: value.aroundRadius * 2 + 'rpx' }"
|
||||
show-menu-by-longpress />
|
||||
<diy-icon v-if="item.iconType == 'icon'" :icon="item.icon"
|
||||
:value="item.style ? item.style : null"
|
||||
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
|
||||
<text :class="['tag', { alone: value.mode == 'text' }]" v-if="item.label.control"
|
||||
:style="{ color: item.label.textColor, backgroundImage: 'linear-gradient(' + item.label.bgColorStart + ',' + item.label.bgColorEnd + ')' }">
|
||||
{{ item.label.text }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="value.mode != 'img'" class="graphic-text"
|
||||
:style="{ fontSize: value.font.size * 2 + 'rpx', fontWeight: value.font.weight, color: value.font.color }">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- #ifdef MP -->
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
</scroll-view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义图形导航
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-graphic-nav',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
pageWidth: '',
|
||||
indicatorDots: false,
|
||||
swiperCurrent: 0
|
||||
};
|
||||
},
|
||||
created() { },
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
computed: {
|
||||
componentStyle() {
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
css += 'box-shadow:' + (this.value.ornament.type == 'shadow' ? '0 0 10rpx ' + this.value.ornament
|
||||
.color :
|
||||
'') + ';';
|
||||
css += 'border:' + (this.value.ornament.type == 'stroke' ? '2rpx solid ' + this.value.ornament.color :
|
||||
'') + ';';
|
||||
return css;
|
||||
},
|
||||
// 滑块容器的高度
|
||||
swiperHeight() {
|
||||
var css = '';
|
||||
var height = 0;
|
||||
if (this.value.mode == 'graphic') {
|
||||
height = (21 + 6 + 14 + 8 + this.value.imageSize) * this.value
|
||||
.pageCount; // 21 = 文字高度,8 = 文字上边距,14 = 上下内边距,8 = 外边距
|
||||
} else if (this.value.mode == 'img') {
|
||||
height = (14 + 8 + this.value.imageSize) * this.value.pageCount; // 14 = 上下内边距,8 = 外边距
|
||||
} else if (this.value.mode == 'text') {
|
||||
height = (21 + 14 + 8) * this.value.pageCount; // 21 = 文字高度,14 = 上下内边距,8 = 外边距
|
||||
}
|
||||
css += 'height:' + height * 2 + 'rpx';
|
||||
return css;
|
||||
},
|
||||
// 是否显示轮播点
|
||||
isIndicatorDots() {
|
||||
var bool = true;
|
||||
bool = this.value.carousel.type == 'hide' || Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount)) == 1 ? false : true;
|
||||
return bool;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
redirectTo(link) {
|
||||
|
||||
if (link.wap_url) {
|
||||
if (this.$util.getCurrRoute() == this.$util.MEMBER_PAGE_URL && !this.storeToken) {
|
||||
this.$refs.login.open(link.wap_url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.$util.diyRedirectTo(link);
|
||||
},
|
||||
swiperChange(e) {
|
||||
this.swiperCurrent = e.detail.current
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
/* 固定显示 */
|
||||
.graphic-nav.fixed-layout>>>.uni-scroll-view-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 单行滑动 */
|
||||
.graphic-nav.singleSlide>>>.uni-scroll-view-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.graphic-nav.pageSlide>>>.uni-swiper-dots-horizontal {
|
||||
bottom: 0rpx;
|
||||
}
|
||||
|
||||
.graphic-nav.pageSlide.straightLine>>>.uni-swiper-dot {
|
||||
width: 30rpx;
|
||||
border-radius: 0;
|
||||
height: 8rpx;
|
||||
}
|
||||
|
||||
.graphic-nav.pageSlide.circle>>>.uni-swiper-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.graphic-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.singleSlide {
|
||||
.graphic-nav-item {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.pageSlide {
|
||||
position: relative;
|
||||
|
||||
.graphic-nav-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.graphic-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 14rpx 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.graphic-text {
|
||||
padding-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
&.alone {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.text {
|
||||
|
||||
.graphic-text {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.graphic-img {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
font-size: 90rpx;
|
||||
|
||||
.tag {
|
||||
position: absolute;
|
||||
top: -10rpx;
|
||||
right: -24rpx;
|
||||
color: #fff;
|
||||
border-radius: 24rpx;
|
||||
border-bottom-left-radius: 0;
|
||||
transform: scale(0.8);
|
||||
padding: 8rpx 16rpx;
|
||||
line-height: 1;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 50rpx;
|
||||
color: $color-sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper-dot-box {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -20rpx;
|
||||
padding-bottom: 8rpx;
|
||||
|
||||
.swiper-dot {
|
||||
background-color: rgba(0, 0, 0, .3);
|
||||
margin: 8rpx;
|
||||
|
||||
&.active {
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&.straightLine {
|
||||
.swiper-dot {
|
||||
width: 30rpx;
|
||||
border-radius: 0;
|
||||
height: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.circle {
|
||||
.swiper-dot {
|
||||
width: 15rpx;
|
||||
border-radius: 50%;
|
||||
height: 15rpx;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -1,500 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-groupbuy" :type="skeletonType" :loading="loading" :configs="skeletonConfig">
|
||||
<view class="diy-groupbuy" :class="[value.template, value.style]" :style="warpCss">
|
||||
<template v-if="value.template == 'row1-of1'">
|
||||
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
|
||||
:class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(index)" show-menu-by-longpress>
|
||||
</image>
|
||||
</view>
|
||||
<view class="content"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl || value.btnStyle.control">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="discount-price" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.groupbuy_price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
'.' + item.groupbuy_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
<button v-if="value.btnStyle.control" :style="{
|
||||
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
|
||||
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
|
||||
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
|
||||
}">
|
||||
{{ value.btnStyle.text }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="value.template == 'horizontal-slide'">
|
||||
<scroll-view v-if="value.slideMode == 'scroll'" class="scroll" :scroll-x="true" :show-scrollbar="false">
|
||||
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
|
||||
:class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(index)" show-menu-by-longpress/>
|
||||
<image class="bg" v-if="value.saleStyle.control"
|
||||
:src="$util.img('public/uniapp/groupbuy/bg.png')" mode="widthFix" />
|
||||
<view class="num" v-if="value.saleStyle.control"
|
||||
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">
|
||||
已团{{ item.sell_num }}件
|
||||
</view>
|
||||
</view>
|
||||
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="discount-price" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.groupbuy_price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
'.' + item.groupbuy_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">¥{{
|
||||
item.price }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<swiper v-if="value.slideMode == 'slide'" :autoplay="false" class="swiper"
|
||||
:style="{ height: swiperHeight }">
|
||||
<swiper-item v-for="(pageItem, pageIndex) in page" :key="pageIndex"
|
||||
:class="['swiper-item', (list.length && [list[pageIndex].length / 3] >= 1) && 'flex-between']">
|
||||
<view class="item" v-for="(item, dataIndex) in list[pageIndex]" :key="dataIndex"
|
||||
@tap.stop="toDetail(item)" :class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(dataIndex)" show-menu-by-longpress/>
|
||||
<image class="bg" v-if="value.saleStyle.control"
|
||||
:src="$util.img('public/uniapp/groupbuy/bg.png')" mode="widthFix" />
|
||||
<view class="num" v-if="value.saleStyle.control"
|
||||
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">已团{{
|
||||
item.sell_num }}件</view>
|
||||
</view>
|
||||
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="discount-price" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.groupbuy_price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
'.' + item.groupbuy_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">¥{{
|
||||
item.price }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</template>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义团购展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-groupbuy',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
skeletonType: '',
|
||||
skeletonConfig: {},
|
||||
list: [],
|
||||
page: 1
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.initSkeleton();
|
||||
this.getData();
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getData();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 商品项样式
|
||||
goodsItemCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.value.ornament.type == 'shadow') {
|
||||
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color + ';';
|
||||
}
|
||||
if (this.value.ornament.type == 'stroke') {
|
||||
obj += 'border:' + '2rpx solid ' + this.value.ornament.color + ';';
|
||||
}
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
if (this.value.template == 'horizontal-slide') {
|
||||
var width = '';
|
||||
if (this.value.slideMode == 'scroll' && this.value.goodsMarginType == 'diy') width = this.rpxUpPx(this
|
||||
.value.goodsMarginNum * 2);
|
||||
else width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this.rpxUpPx(this.value
|
||||
.margin.both * 2) * 2] / 6;
|
||||
obj += 'margin-left:' + width + 'px;';
|
||||
obj += 'margin-right:' + width + 'px;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
swiperHeight() {
|
||||
if (this.value.nameLineMode == 'multiple') return this.value.ornament.type == 'shadow' ? '404rpx' :
|
||||
'392rpx';
|
||||
return this.value.ornament.type == 'shadow' ? '376rpx' : '368rpx';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initSkeleton() {
|
||||
if (this.value.template == 'row1-of1') {
|
||||
|
||||
// 单列 风格
|
||||
this.skeletonType = 'list';
|
||||
this.skeletonConfig = {
|
||||
textRows: 2
|
||||
};
|
||||
|
||||
} else if (this.value.template == 'horizontal-slide') {
|
||||
|
||||
// 横向滑动 风格
|
||||
this.skeletonType = 'waterfall';
|
||||
this.skeletonConfig = {
|
||||
gridRows: 1,
|
||||
gridColumns: 3,
|
||||
headHeight: '200rpx',
|
||||
textRows: 2,
|
||||
textWidth: ['100%', '80%']
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
rpxUpPx(res) {
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
var data = (screenWidth * parseInt(res)) / 750;
|
||||
return Math.floor(data);
|
||||
},
|
||||
getData() {
|
||||
var data = {
|
||||
num: this.value.count
|
||||
};
|
||||
if (this.value.sources == 'diy') {
|
||||
data.num = 0;
|
||||
data.goods_id_arr = this.value.goodsId.toString();
|
||||
}
|
||||
this.$api.sendRequest({
|
||||
url: '/groupbuy/api/goods/lists',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.list = res.data;
|
||||
|
||||
// 切屏滚动,每页显示固定数量
|
||||
if (this.value.template == 'horizontal-slide' && this.value.slideMode == 'slide') {
|
||||
let size = 3;
|
||||
let temp = [];
|
||||
this.page = Math.ceil(this.list.length / size);
|
||||
for (var i = 0; i < this.page; i++) {
|
||||
temp[i] = [];
|
||||
for (var j = i * size; j < this.list.length; j++) {
|
||||
if (temp[i].length == size) break;
|
||||
temp[i].push(this.list[j]);
|
||||
}
|
||||
}
|
||||
this.list = temp;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
toDetail(e) {
|
||||
this.$util.redirectTo('/pages_promotion/groupbuy/detail', {
|
||||
groupbuy_id: e.groupbuy_id
|
||||
});
|
||||
},
|
||||
imageError(index) {
|
||||
this.list[index].goods_image = this.$util.getDefaultImage().goods;
|
||||
this.$forceUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
::v-deep .uni-scroll-view ::-webkit-scrollbar {
|
||||
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::v-deep ::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
scroll-view ::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.diy-groupbuy {
|
||||
&.row1-of1 {
|
||||
.item {
|
||||
display: flex;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 16rpx;
|
||||
|
||||
&.shadow {
|
||||
margin: 8rpx 8rpx 20rpx 8rpx;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
|
||||
>image {
|
||||
width: 200rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
margin-top: 6rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
position: relative;
|
||||
|
||||
.discount-price {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
line-height: 1;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 4rpx;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
color: $base-color;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
bottom: 10rpx;
|
||||
right: 20rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
background-color: $base-color;
|
||||
color: #fff;
|
||||
min-width: 112rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal-slide {
|
||||
.scroll {
|
||||
width: calc(100% - 40rpx);
|
||||
padding: 20rpx;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.item.shadow {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-block;
|
||||
width: 200rpx;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(3n + 3) {
|
||||
width: 198rpx;
|
||||
}
|
||||
|
||||
&.shadow {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
|
||||
>image {
|
||||
width: 200rpx;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 60rpx;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.num {
|
||||
width: 180rpx;
|
||||
position: absolute;
|
||||
bottom: 10rpx;
|
||||
padding-left: 20rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&.multi-content {
|
||||
height: 158rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
line-height: 1.3;
|
||||
|
||||
&.multi-hidden {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
|
||||
.discount-price {
|
||||
white-space: nowrap;
|
||||
margin-top: auto;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
margin-right: 4rpx;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
color: $base-color;
|
||||
}
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: $font-size-tag;
|
||||
color: $color-tip;
|
||||
line-height: 1;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 200rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-hot-area" :style="hotAreaWarp" class="hot-area-box">
|
||||
<view class="simple-graph-wrap">
|
||||
<image :style="{ height: value.imgHeight }" :src="$util.img(value.imageUrl)" mode="widthFix"
|
||||
show-menu-by-longpress />
|
||||
<!-- 热区功能 -->
|
||||
<view class="heat-map" v-for="(mapItem, mapIndex) in value.heatMapData" :key="mapIndex" :style="{
|
||||
width: mapItem.width + '%',
|
||||
height: mapItem.height + '%',
|
||||
left: mapItem.left + '%',
|
||||
top: mapItem.top + '%'
|
||||
}" @tap.stop="$util.diyRedirectTo(mapItem.link)"></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 热区展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-hot-area',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
created() { },
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
computed: {
|
||||
hotAreaWarp: function () {
|
||||
var obj = '';
|
||||
obj = 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hot-area-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-graph-wrap {
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heat-map {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-icon" class="diy-icon" :style="iconBgStyle">
|
||||
<text class="js-icon" :class="iconClass" :style="iconStyle"></text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 图标展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-icon',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
value: {
|
||||
type: Object,
|
||||
default: function () {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
computed: {
|
||||
iconClass() {
|
||||
var _class = ' ' + this.icon;
|
||||
if (this.value && this.value.iconColor.length > 1) _class += ' gradient';
|
||||
return _class;
|
||||
},
|
||||
iconBgStyle() {
|
||||
if (!this.value) return {};
|
||||
|
||||
var style = {
|
||||
'border-radius': this.value.bgRadius + '%',
|
||||
'background': ''
|
||||
};
|
||||
|
||||
if (this.value.iconBgImg) style['background'] += 'url(' + this.$util.img(this.value.iconBgImg) + ') no-repeat bottom / contain'
|
||||
if (this.value.iconBgColor.length) {
|
||||
if (style.background) style.background += ',';
|
||||
if (this.value.iconBgColor.length == 1) {
|
||||
style.background += this.value.iconBgColor[0];
|
||||
} else {
|
||||
style['background'] += 'linear-gradient(' + this.value.iconBgColorDeg + 'deg, ' + this.value.iconBgColor.join(',') + ')';
|
||||
}
|
||||
}
|
||||
return this.$util.objToStyle(style);
|
||||
},
|
||||
iconStyle() {
|
||||
if (!this.value) return {};
|
||||
|
||||
var style = {
|
||||
'font-size': this.value.fontSize + '%'
|
||||
}
|
||||
if (this.value.iconColor.length == 1) {
|
||||
style.color = this.value.iconColor[0];
|
||||
} else {
|
||||
style['background'] = 'linear-gradient(' + this.value.iconColorDeg + 'deg, ' + this.value.iconColor.join(',') + ')';
|
||||
}
|
||||
return this.$util.objToStyle(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.diy-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 100%;
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.js-icon {
|
||||
font-size: 50%;
|
||||
line-height: 1;
|
||||
padding: 1rpx;
|
||||
|
||||
&.gradient {
|
||||
-webkit-background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,461 +0,0 @@
|
||||
<template>
|
||||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||
:scroll-x="value.showStyle == 'singleSlide'">
|
||||
<view class="uni-scroll-view-content">
|
||||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||
style="margin-right: 28rpx;">
|
||||
<!-- 图片部分 -->
|
||||
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
||||
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}">
|
||||
<image v-if="item.link.wap_url" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||||
<image v-else :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
||||
</view>
|
||||
|
||||
<!-- 文字部分 -->
|
||||
<text class="image-text" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.color + ';'
|
||||
}">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import DiyMinx from './minx.js'
|
||||
|
||||
export default {
|
||||
name: 'diy-image-nav',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
pageWidth: '',
|
||||
indicatorDots: false,
|
||||
swiperCurrent: 0,
|
||||
// H5 鼠标拖拽状态(singleSlide 模式专用)
|
||||
slideIsDragging: false,
|
||||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||||
slideStartX: 0,
|
||||
slideStartScrollLeft: 0,
|
||||
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 组件创建时的逻辑
|
||||
},
|
||||
mounted() {
|
||||
// H5 端:为 singleSlide 模式的 scroll-view 手动绑定 mouse/wheel 事件
|
||||
// 原因:模板里写 @mousedown/@wheel 在小程序/鸿蒙端会产生"未知事件"警告,
|
||||
// 所以 H5 专属事件全部在 mounted 中通过 addEventListener 绑定。
|
||||
// $nextTick 是为了确保 ref 在 DOM 中已就绪(滚动容器总是存在的,无 v-if)。
|
||||
// #ifdef H5
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
self.bindSlideMouseEvents();
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
beforeDestroy() {
|
||||
// #ifdef H5
|
||||
this.unbindSlideMouseEvents();
|
||||
// #endif
|
||||
},
|
||||
watch: {
|
||||
componentRefresh(newValue) {
|
||||
// 监听组件刷新
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
componentStyle() {
|
||||
let style = '';
|
||||
style += 'background-color:' + this.value.componentBgColor + ';';
|
||||
|
||||
if (this.value.componentAngle == 'round') {
|
||||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||
style += 'border-top-right-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||
style += 'border-bottom-left-radius:' + (2 * this.value.bottomAroundRadius) + 'rpx;';
|
||||
style += 'border-bottom-right-radius:' + (2 * this.value.bottomAroundRadius) + 'rpx;';
|
||||
}
|
||||
|
||||
style += 'box-shadow:' + (this.value.ornament.type == 'shadow' ? '0 0 10rpx ' + this.value.ornament.color : '') + ';';
|
||||
style += 'border:' + (this.value.ornament.type == 'stroke' ? '2rpx solid ' + this.value.ornament.color : '') + ';';
|
||||
|
||||
return style;
|
||||
},
|
||||
|
||||
swiperHeight() {
|
||||
let height = 0;
|
||||
|
||||
if (this.value.mode == 'graphic') {
|
||||
height = (49 + this.value.imageSize) * this.value.pageCount;
|
||||
} else if (this.value.mode == 'img') {
|
||||
height = (22 + this.value.imageSize) * this.value.pageCount;
|
||||
} else if (this.value.mode == 'text') {
|
||||
height = 43 * this.value.pageCount;
|
||||
}
|
||||
|
||||
return 'height:' + (2 * height) + 'rpx';
|
||||
},
|
||||
|
||||
isIndicatorDots() {
|
||||
return this.value.carousel.type != 'hide' &&
|
||||
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 页面跳转
|
||||
redirectTo(link) {
|
||||
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||
this.$util.diyRedirectTo(link);
|
||||
} else {
|
||||
this.$refs.login.open(link.wap_url);
|
||||
}
|
||||
},
|
||||
|
||||
// 轮播切换
|
||||
swiperChange(event) {
|
||||
this.swiperCurrent = event.detail.current;
|
||||
},
|
||||
|
||||
// ========== H5 singleSlide 鼠标拖拽支持 ==========
|
||||
// 背景:uni-app 的 scroll-view 在 H5 端被编译为 div,虽然 overflow-x: auto 可让
|
||||
// 浏览器渲染横向滚动条,但 scrollLeft 不响应鼠标拖拽(uni 的滚动逻辑不监听 mouse)。
|
||||
// 因此 H5 下需要手动绑定 mousedown/mousemove/mouseup,模拟拖拽改变 scrollLeft。
|
||||
// 同时绑定 wheel,让鼠标滚轮也能横向滚动(浏览器默认滚轮是垂直,需 preventDefault 后手动改 scrollLeft)。
|
||||
// 仅在 singleSlide 模式下生效;fixed / pageSlide 走原逻辑。
|
||||
//
|
||||
// 关键设计:H5 专属事件全部在 mounted 中通过 addEventListener 绑定,而不是写在模板上,
|
||||
// 避免小程序/鸿蒙端产生"未知事件"编译警告。所有端共用同一份模板。
|
||||
bindSlideMouseEvents() {
|
||||
// #ifdef H5
|
||||
// 防止重复绑定
|
||||
if (this._slideEventsBound) return;
|
||||
var outerEl = this.getSlideOuterEl();
|
||||
var innerEl = this.getSlideContainer();
|
||||
if (!outerEl || !innerEl) return;
|
||||
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
|
||||
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
|
||||
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
|
||||
this._slideOuterEl = outerEl;
|
||||
this._slideEl = innerEl;
|
||||
this._slideEventsBound = true;
|
||||
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
||||
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
||||
document.addEventListener('wheel', this.onSlideWheel, { passive: false, capture: true });
|
||||
// click 拦截:如果在 mousemove 中真的拖动了容器(mousedown 后 deltaX > 5),
|
||||
// mouseup 时进入"忽略接下来一次 click"状态,防止拖拽结束后误触发跳转。
|
||||
document.addEventListener('click', this.onSlideMaybeClick, true);
|
||||
window.addEventListener('mousemove', this.onSlideMouseMove);
|
||||
window.addEventListener('mouseup', this.onSlideMouseUp);
|
||||
// #endif
|
||||
},
|
||||
unbindSlideMouseEvents() {
|
||||
// #ifdef H5
|
||||
document.removeEventListener('mousedown', this.onSlideMouseDown, true);
|
||||
document.removeEventListener('wheel', this.onSlideWheel, true);
|
||||
document.removeEventListener('click', this.onSlideMaybeClick, true);
|
||||
window.removeEventListener('mousemove', this.onSlideMouseMove);
|
||||
window.removeEventListener('mouseup', this.onSlideMouseUp);
|
||||
this._slideOuterEl = null;
|
||||
this._slideEventsBound = false;
|
||||
// #endif
|
||||
},
|
||||
getSlideContainer() {
|
||||
// #ifdef H5
|
||||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
|
||||
// 嵌套结构:
|
||||
// <uni-scroll-view> ← 自定义元素
|
||||
// <div class="uni-scroll-view"> ← 第1层,visible
|
||||
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
|
||||
// 真正能响应 scrollLeft 的是第 2 层 div。
|
||||
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
|
||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||
if (!componentRoot) return null;
|
||||
// 找 overflowX:auto 的 div.uni-scroll-view
|
||||
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var s = getComputedStyle(candidates[i]).overflowX;
|
||||
if (s === 'auto' || s === 'scroll') {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
|
||||
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
||||
// #endif
|
||||
},
|
||||
getSlideOuterEl() {
|
||||
// #ifdef H5
|
||||
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
|
||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||
if (!componentRoot) return null;
|
||||
var sc = componentRoot.querySelector('uni-scroll-view');
|
||||
return sc || null;
|
||||
// #endif
|
||||
},
|
||||
onSlideMouseDown(e) {
|
||||
// #ifdef H5
|
||||
if (this.value.showStyle != 'singleSlide') return;
|
||||
// document 级别监听,只处理落在容器内的事件
|
||||
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
|
||||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||||
if (!this._slideOuterEl) return;
|
||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
var x = e.clientX, y = e.clientY;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||
var el = this.getSlideContainer();
|
||||
if (!el) return;
|
||||
this.slideIsDragging = true;
|
||||
this.slideStartX = x;
|
||||
this.slideStartScrollLeft = el.scrollLeft;
|
||||
this.slideDidDrag = false; // mousedown 时清零:5px 阈值后才置 true
|
||||
this._suppressNextClick = false;
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
// 注意:这里不调 stopPropagation,否则纯点击也不能触发 click/tap → 跳转
|
||||
// #endif
|
||||
},
|
||||
onSlideMouseMove(e) {
|
||||
// #ifdef H5
|
||||
if (!this.slideIsDragging) return;
|
||||
var el = this.getSlideContainer();
|
||||
if (!el) return;
|
||||
var x = (e.clientX != null) ? e.clientX : 0;
|
||||
var deltaX = x - this.slideStartX;
|
||||
// 5px 阈值:超过后标记已经拖动,后续 mouseup 会忽略接下来一次 click
|
||||
if (!this.slideDidDrag && Math.abs(deltaX) >= 5) {
|
||||
this.slideDidDrag = true;
|
||||
}
|
||||
// 反向:手指/鼠标往右拖 → 内容向右滚 → scrollLeft 减小
|
||||
el.scrollLeft = this.slideStartScrollLeft - deltaX;
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
// #endif
|
||||
},
|
||||
onSlideMouseUp() {
|
||||
// #ifdef H5
|
||||
this.slideIsDragging = false;
|
||||
// 如果确实拖动过,标记接下来一次 click 应当被拦截(防止拖拽结束后误触发跳转)
|
||||
if (this.slideDidDrag) {
|
||||
this._suppressNextClick = true;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
// click 拦截:如果 mouseup 时设置了 _suppressNextClick,就拦截这一次 click 并清除标记
|
||||
onSlideMaybeClick(e) {
|
||||
// #ifdef H5
|
||||
if (!this._suppressNextClick) return;
|
||||
if (!e.target) return;
|
||||
var outer = this._slideOuterEl;
|
||||
if (!outer || !outer.contains(e.target)) return;
|
||||
this._suppressNextClick = false;
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
if (e.stopPropagation) e.stopPropagation();
|
||||
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
|
||||
// #endif
|
||||
},
|
||||
onSlideWheel(e) {
|
||||
// #ifdef H5
|
||||
if (this.value.showStyle != 'singleSlide') return;
|
||||
if (!this._slideOuterEl) return;
|
||||
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
|
||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
var x = e.clientX, y = e.clientY;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||
var el = this.getSlideContainer();
|
||||
if (!el) return;
|
||||
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
||||
var dx = e.deltaX || 0;
|
||||
var dy = e.deltaY || 0;
|
||||
// 优先用 deltaX(触控板横向 / Shift+滚轮),否则用 deltaY(普通滚轮 → 转横向)
|
||||
var step = dx !== 0 ? dx : dy;
|
||||
el.scrollLeft += step;
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
if (e.stopPropagation) e.stopPropagation();
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.fixed-layout {
|
||||
.uni-scroll-view-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&.singleSlide {
|
||||
.uni-scroll-view-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.image-nav-item {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ========== H5 singleSlide 鼠标拖拽支持:横向滚动 ==========
|
||||
// uni-app 在 H5 端会把 scroll-view 编译为 div,但默认样式不一定是
|
||||
// overflow-x:auto → 横向内容溢出后无法滚动。需要显式声明。
|
||||
// #ifdef H5
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge legacy
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||||
-webkit-overflow-scrolling: touch;
|
||||
// #endif
|
||||
}
|
||||
|
||||
&.pageSlide {
|
||||
position: relative;
|
||||
|
||||
.uni-swiper-dots-horizontal {
|
||||
bottom: 0rpx;
|
||||
}
|
||||
|
||||
&.straightLine {
|
||||
.uni-swiper-dot {
|
||||
width: 30rpx;
|
||||
border-radius: 0;
|
||||
height: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.circle {
|
||||
.uni-swiper-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-nav-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 14rpx 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.image-text {
|
||||
padding-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
|
||||
&.alone {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.text {
|
||||
.image-text {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-img {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 90rpx;
|
||||
|
||||
.tag {
|
||||
position: absolute;
|
||||
top: -10rpx;
|
||||
right: -24rpx;
|
||||
color: #fff;
|
||||
border-radius: 24rpx;
|
||||
border-bottom-left-radius: 0;
|
||||
-webkit-transform: scale(0.8);
|
||||
transform: scale(0.8);
|
||||
padding: 8rpx 16rpx;
|
||||
line-height: 1;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 50rpx;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper-dot-box {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -20rpx;
|
||||
padding-bottom: 8rpx;
|
||||
|
||||
.swiper-dot {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
margin: 8rpx;
|
||||
|
||||
&.active {
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
&.straightLine {
|
||||
.swiper-dot {
|
||||
width: 30rpx;
|
||||
border-radius: 0;
|
||||
height: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.circle {
|
||||
.swiper-dot {
|
||||
width: 15rpx;
|
||||
border-radius: 50%;
|
||||
height: 15rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,317 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-img-ads" class="single-graph">
|
||||
<view :style="imgAdsMarginWarp" class="swiper-box">
|
||||
<block v-if="imgAdsValue.list.length == 1">
|
||||
<view class="simple-graph-wrap" :style="imgAdsSwiper" @tap.stop="handlerClick(imgAdsValue.list[0].link)">
|
||||
<image :style="{ height: imgAdsValue.list[0].imgHeight }"
|
||||
:src="$util.img(imgAdsValue.list[0].imageUrl)" mode="widthFix" show-menu-by-longpress />
|
||||
</view>
|
||||
</block>
|
||||
<swiper v-else class="swiper" :style="{ height: swiperHeight }" :class="{
|
||||
'swiper-left': imgAdsValue.indicatorLocation == 'left',
|
||||
'swiper-right': imgAdsValue.indicatorLocation == 'right',
|
||||
'ns-indicator-dots': imgAdsValue.carouselStyle == 'line'
|
||||
}" :autoplay="true" :interval="imgAdsValue.interval" circular="true" :indicator-dots="isDots"
|
||||
indicator-color="rgba(130, 130, 130, .5)" :indicator-active-color="imgAdsValue.indicatorColor"
|
||||
@change="swiperChange">
|
||||
<swiper-item class="swiper-item" :style="imgAdsSwiper" v-for="(item, index) in imgAdsValue.list"
|
||||
:key="index" v-if="item.imageUrl" @tap.stop="handlerClick(item.link)">
|
||||
<view class="item" :style="imgAdsSwiper + 'height: ' + item.imgHeight">
|
||||
<image :src="$util.img(item.imageUrl)" :mode="item.imageMode || 'scaleToFill'"
|
||||
show-menu-by-longpress />
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view v-if="imgAdsValue.list.length > 1 && value.indicatorIsShow" :class="[
|
||||
'swiper-dot-box',
|
||||
{ straightLine: imgAdsValue.carouselStyle == 'line' },
|
||||
{ 'swiper-left': imgAdsValue.indicatorLocation == 'left' },
|
||||
{ 'swiper-right': imgAdsValue.indicatorLocation == 'right' }
|
||||
]">
|
||||
<view v-for="(numItem, numIndex) in imgAdsValue.list.length" :key="numIndex"
|
||||
:class="['swiper-dot', { active: numIndex == swiperIndex }]"
|
||||
:style="[numIndex == swiperIndex && { backgroundColor: imgAdsValue.indicatorColor }]"></view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 图片广告
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-img-ads',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDots: true,
|
||||
swiperHeight: 0,
|
||||
imgAdsValue: null, // 深拷贝一遍数据,防止动态计算图片展示尺寸的时候,影响到父级的数据,导致二次渲染的时候,数据错误
|
||||
swiperIndex: 0
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.calcSingleRow();
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) { }
|
||||
},
|
||||
computed: {
|
||||
imgAdsMarginWarp: function () {
|
||||
var obj = '';
|
||||
obj = 'background-color:' + this.value.componentBgColor + ';';
|
||||
return obj;
|
||||
},
|
||||
imgAdsSwiper: function () {
|
||||
var obj = '';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
singleGraphBg: function () {
|
||||
var imgArr = [];
|
||||
for (let i = 0; i < this.imgAdsValue.list.length; i++) {
|
||||
let item = this.imgAdsValue.list[i];
|
||||
imgArr[i] = parseFloat(item.imgHeight);
|
||||
}
|
||||
imgArr.sort(function (a, b) {
|
||||
return b - a;
|
||||
});
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.imgAdsValue.backgroundColor + ';';
|
||||
obj += 'height:' + imgArr[0] * (this.imgAdsValue.backgroundHeight / 100) * 2 + 'rpx;';
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
swiperChange(e) {
|
||||
this.swiperIndex = e.detail.current;
|
||||
},
|
||||
calcSingleRow() {
|
||||
let minHeight = 0;
|
||||
let systemInfo = uni.getSystemInfoSync()
|
||||
// 深拷贝一层数据,防止数据更改越权
|
||||
this.imgAdsValue = JSON.parse(JSON.stringify(this.value));
|
||||
this.imgAdsValue.list.forEach((item, index) => {
|
||||
var ratio = item.imgHeight / item.imgWidth;
|
||||
item.imgWidth = systemInfo.windowWidth;
|
||||
item.imgWidth -= this.value.margin.both * 2;
|
||||
item.imgHeight = item.imgWidth * ratio;
|
||||
|
||||
// 获取最大高度 if (maxHeight == 0 || maxHeight < item.imgHeight) maxHeight = item.imgHeight;
|
||||
if (minHeight == 0 || minHeight > item.imgHeight) minHeight = item.imgHeight;
|
||||
});
|
||||
|
||||
this.imgAdsValue.list.forEach((item, index) => {
|
||||
item.imgHeight = minHeight + 'px';
|
||||
this.swiperHeight = minHeight + 'px';
|
||||
});
|
||||
this.imgAdsValue.indicatorColor = this.imgAdsValue.indicatorColor || '#fff';
|
||||
|
||||
if (this.value.indicatorIsShow === undefined) {
|
||||
this.value.indicatorIsShow = true; // 控制指示点是否展示
|
||||
}
|
||||
|
||||
// 是否显示指示器
|
||||
if (this.imgAdsValue.list.length <= 1) {
|
||||
this.isDots = false;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
this.isDots = this.value.indicatorIsShow;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
this.isDots = false;
|
||||
// #endif
|
||||
},
|
||||
async handlerClick(link) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'img-ads-tap', data: link, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.$util.diyRedirectTo(link);
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.single-graph {
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-graph-wrap {
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heat-map {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.item.active text {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
color: #ffffff;
|
||||
font-size: $font-size-tag;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
line-height: 40rpx;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding: 0 10rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.swiper-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.swiper {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swiper-item {
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
// position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.heat-map {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper-dot-box {
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 40rpx 8rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.swiper-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&.swiper-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.swiper-dot {
|
||||
background-color: #b2b2b2;
|
||||
width: 15rpx;
|
||||
border-radius: 50%;
|
||||
height: 15rpx;
|
||||
margin: 8rpx;
|
||||
|
||||
&.active {
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.straightLine {
|
||||
.swiper-dot {
|
||||
width: 18rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 4rpx;
|
||||
|
||||
&.active {
|
||||
width: 36rpx;
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.swiper ::v-deep .uni-swiper-dots-horizontal {
|
||||
bottom: 25rpx;
|
||||
}
|
||||
|
||||
.swiper-left ::v-deep .uni-swiper-dots-horizontal {
|
||||
left: 40rpx;
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.swiper-right ::v-deep .uni-swiper-dots-horizontal {
|
||||
right: 40rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.carousel-angle ::v-deep .uni-swiper-dots-horizontal .uni-swiper-dot {
|
||||
width: 24rpx;
|
||||
border-radius: 0;
|
||||
height: 8rpx;
|
||||
}
|
||||
|
||||
.swiper.ns-indicator-dots ::v-deep .uni-swiper-dot {
|
||||
width: 18rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.swiper.ns-indicator-dots ::v-deep .uni-swiper-dot-active {
|
||||
width: 36rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,866 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-index-page">
|
||||
<view class="bg" :style="warpCss">
|
||||
<view class="index-page-content">
|
||||
<view class="nav-top-category" :style="categoryCss">
|
||||
<scroll-view v-if="value" scroll-with-animation class="diyIndex" scroll-x="true"
|
||||
:scroll-into-view="'a' + pageIndex"
|
||||
:style="{ background: value.backgroundColor ? value.backgroundColor : '', width: 'calc(100% - 48rpx)' }"
|
||||
@touchmove.stop>
|
||||
<view class="item" :id="'a' + index" v-for="(item, index) in cateList" :key="index"
|
||||
@tap.stop="changePageIndex(index)" :class="{ fill: value.styleType == 'fill' }"
|
||||
:style="{ background: index == pageIndex && value.styleType == 'fill' ? value.selectColor : '' }">
|
||||
<view class="text-con" :class="index == pageIndex ? 'active' : ''" :style="{
|
||||
color: index == pageIndex ? '' : value.noColor
|
||||
}" v-if="value.styleType == 'fill'">
|
||||
{{ item.short_name ? item.short_name : item.category_name }}
|
||||
</view>
|
||||
<view class="text-con" :class="index == pageIndex ? 'active' : ''"
|
||||
:style="{ color: index == pageIndex ? value.selectColor : value.noColor }" v-else>
|
||||
{{ item.short_name ? item.short_name : item.category_name }}
|
||||
</view>
|
||||
<view class="color-base-bg line" v-if="index == pageIndex && value.styleType != 'fill'"
|
||||
:style="{ background: value.selectColor ? value.selectColor + '!important' : 'rgba(0,0,0,0)' + '!important' }">
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="iconfont icon-unfold unfold-arrows" :style="{ color: value.moreColor }"
|
||||
@tap.stop="unfoldMenu"></text>
|
||||
</view>
|
||||
<uni-popup ref="navTopCategoryPop" type="top" :top="uniPopTop">
|
||||
<view class="nav-topcategory-pop">
|
||||
<text v-for="(item, index) in cateList" :key="index"
|
||||
:class="['category-item', { 'color-base-text color-base-border active': pageIndex == index }]"
|
||||
@tap.stop="changePageIndex(index)">
|
||||
{{ item.short_name ? item.short_name : item.category_name }}
|
||||
</text>
|
||||
</view>
|
||||
</uni-popup>
|
||||
<view class="nav_top_category-fill" :style="{ height: moduleHeight }"></view>
|
||||
<block v-if="pageIndex == 0">
|
||||
<slot name="components"></slot>
|
||||
<slot></slot>
|
||||
</block>
|
||||
<block v-else>
|
||||
<slot name="components"></slot>
|
||||
<view class="index-category-box">
|
||||
<view class="category-goods" v-show="!isloading">
|
||||
<mescroll-uni :top="uniPopTop" ref="mescroll" @getData="getGoodsList"
|
||||
:background="'url(' + $util.img(bgUrl) + ') 0px -50px / 100% no-repeat'"
|
||||
:paddingBoth="'30rpx'" @touchmove.prevent.stop>
|
||||
<block slot="list">
|
||||
<!-- 二级分类 -->
|
||||
<view class="twoCategorylist"
|
||||
v-if="twoCategorylist != 'undefined' && twoCategorylist && twoCategorylist.length > 0">
|
||||
<view class="twoCategory min" v-if="twoCategorylist.length <= 5">
|
||||
<view class="twoCategory-page">
|
||||
<view class="swiper-item" v-for="(item, index) in twoCategorylist"
|
||||
:key="index" @tap.stop="toCateGoodsList(item.category_id_2, 2)">
|
||||
<view class="item-box">
|
||||
<image :src="$util.img(item.image)" v-if="item.image"
|
||||
mode="aspectFill" show-menu-by-longpress/>
|
||||
<image :src="$util.getDefaultImage().goods" v-else
|
||||
mode="aspectFill" show-menu-by-longpress/>
|
||||
<view>{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="twoCategory base"
|
||||
v-if="twoCategorylist.length > 5 && twoCategorylist.length <= 10">
|
||||
<view class="twoCategory-page">
|
||||
<view class="swiper-item" v-for="(item, index) in twoCategorylist"
|
||||
:key="index" @tap.stop="toCateGoodsList(item.category_id_2, 2)">
|
||||
<view class="item-box">
|
||||
<image :src="$util.img(item.image)" v-if="item.image"
|
||||
mode="aspectFill" show-menu-by-longpress/>
|
||||
<image :src="$util.getDefaultImage().goods" v-else
|
||||
mode="aspectFill" show-menu-by-longpress/>
|
||||
<view>{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<swiper class="twoCategory big" :duration="500"
|
||||
v-if="twoCategorylist.length > 10" @change="swiperTocategoryChange">
|
||||
<swiper-item class="twoCategory-page" v-for="page in maxPage" :key="page">
|
||||
<view class="swiper-item" v-for="(item, index) in twoCategorylist"
|
||||
:key="index" v-if="index >= (page - 1) * 10 && index < page * 10"
|
||||
@tap.stop="toCateGoodsList(item.category_id_2, 2)">
|
||||
<view class="item-box">
|
||||
<image :src="item.image" mode="aspectFill" show-menu-by-longpress/>
|
||||
<view>{{ item.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="dot-box">
|
||||
<view class="dot-item" v-for="page in maxPage" v-if="maxPage > 1"
|
||||
:key="page"
|
||||
:class="twoCategorylistId == page - 1 ? 'active color-base-bg' : ''">
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分类广告 -->
|
||||
<image class="category_adv" v-if="cateList[pageIndex].image_adv"
|
||||
:src="$util.img(cateList[pageIndex].image_adv)" mode="widthFix" show-menu-by-longpress/>
|
||||
|
||||
<view class="goods-list double-column" v-if="goodsList[pageIndex].list.length">
|
||||
<view class="goods-item" v-for="(item, index) in goodsList[pageIndex].list"
|
||||
:key="index" @tap.stop="toDetail(item)">
|
||||
<view class="goods-img">
|
||||
<image :src="goodsImg(item.goods_image)" mode="widthFix"
|
||||
@error="imgError(index)" show-menu-by-longpress/>
|
||||
<view class="color-base-bg goods-tag"
|
||||
v-if="value.goodsTag == 'default' && goodsTag(item) != ''">{{
|
||||
goodsTag(item) }}</view>
|
||||
<view class="goods-tag-img" v-if="value.goodsTag == 'diy'">
|
||||
<image :src="$util.img(value.tagImg.imageUrl)" show-menu-by-longpress/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="name-wrap">
|
||||
<view class="goods-name">{{ isEnEnv ? item.en_goods_name : item.goods_name }}</view>
|
||||
</view>
|
||||
<view class="lineheight-clear">
|
||||
<view class="discount-price">
|
||||
<text class="unit color-base-text font-size-tag">{{
|
||||
$lang('common.currencySymbol') }}</text>
|
||||
<text class="price color-base-text font-size-toolbar">{{
|
||||
showPrice(item) }}</text>
|
||||
</view>
|
||||
<view class="member-price-tag"
|
||||
v-if="item.member_price && item.member_price == showPrice(item)">
|
||||
<image :src="$util.img('public/uniapp/index/VIP.png')"
|
||||
mode="widthFix" show-menu-by-longpress/>
|
||||
</view>
|
||||
<view class="member-price-tag" v-else-if="item.promotion_type == 1">
|
||||
<image :src="$util.img('public/uniapp/index/discount.png')"
|
||||
mode="widthFix" show-menu-by-longpress/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pro-info">
|
||||
<view class="delete-price font-size-activity-tag color-tip"
|
||||
v-if="showMarketPrice(item)">
|
||||
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
|
||||
<text>{{ showMarketPrice(item) }}</text>
|
||||
</view>
|
||||
<view class="sale font-size-activity-tag color-tip">{{
|
||||
$lang('components-diy.diy-index-page.soldCount', { count: item.sale_num, unit: item.unit || $lang('components-diy.diy-index-page.soldUnit') }) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!isloading && goodsList[pageIndex].list.length == 0">
|
||||
<ns-empty :text="$lang('components-diy.diy-index-page.noGoodsInCategory')" :isIndex="false"></ns-empty>
|
||||
</view>
|
||||
</block>
|
||||
</mescroll-uni>
|
||||
|
||||
<!-- <ns-empty v-else-if="!isloading" :isIndex="false" text="该分类下暂无商品"></ns-empty> -->
|
||||
</view>
|
||||
<view class="loading" v-show="isloading"><ns-loading ref="loading"></ns-loading></view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-index-page',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
},
|
||||
bgUrl: {
|
||||
type: String
|
||||
},
|
||||
scrollTop: {
|
||||
type: [String, Number],
|
||||
default: '0'
|
||||
},
|
||||
diyGlobal: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
pageIndex: 0, //当前选中分类id
|
||||
cateList: [{
|
||||
//header分类
|
||||
category_name: ''
|
||||
}],
|
||||
twoCategorylist: [], //二级分类
|
||||
twoCategorylistId: 0, //二级分类所在的swiper
|
||||
goodsList: {},
|
||||
isloading: true,
|
||||
top: 0,
|
||||
isUnfold: true, //是否展开菜单
|
||||
moduleHeight: '' //组件高度
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += (this.bgUrl ? 'background:' + 'url(' + this.$util.img(this.bgUrl) + ') no-repeat 0 0/100%' : '') + ';';
|
||||
return obj;
|
||||
},
|
||||
categoryCss() {
|
||||
var obj = '';
|
||||
obj += 'top:' + this.fixedTop + ';';
|
||||
// obj += 'background-color:' + (this.value.componentBgColor || this.value.pageBgColor) + ';';
|
||||
obj += 'background-color:' + this.topNavColor + ';';
|
||||
return obj;
|
||||
},
|
||||
maxPage() {
|
||||
let num = 0;
|
||||
if (this.twoCategorylist && this.twoCategorylist.length) {
|
||||
num = Math.ceil(this.twoCategorylist.length / 10);
|
||||
}
|
||||
return num;
|
||||
},
|
||||
type() {
|
||||
if (this.value) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
topNavColor() {
|
||||
var color = this.value.componentBgColor || this.value.pageBgColor;
|
||||
if (this.diyGlobal.topNavBg && this.scrollTop > 20) color = this.diyGlobal.topNavColor;
|
||||
return color;
|
||||
},
|
||||
fixedTop() {
|
||||
let diyPositionObj = this.$store.state.diyGroupPositionObj;
|
||||
let data = 0;
|
||||
if (diyPositionObj.diySearch && diyPositionObj.diyIndexPage && diyPositionObj.nsNavbar) {
|
||||
if (diyPositionObj.diySearch.moduleIndex > diyPositionObj.diyIndexPage.moduleIndex) data =
|
||||
diyPositionObj.nsNavbar.originalVal + 'px';
|
||||
else data = diyPositionObj.nsNavbar.originalVal + diyPositionObj.diySearch.originalVal + 'px';
|
||||
} else if (diyPositionObj.diyIndexPage && diyPositionObj.nsNavbar) {
|
||||
data = diyPositionObj.nsNavbar.originalVal + 'px';
|
||||
}
|
||||
return data;
|
||||
},
|
||||
// 分类导航展开菜单的位置
|
||||
uniPopTop() {
|
||||
let diyPositionObj = this.$store.state.diyGroupPositionObj;
|
||||
let data = '0';
|
||||
|
||||
if (this.fixedTop && diyPositionObj.diyIndexPage)
|
||||
data = Number.parseFloat(this.fixedTop) + diyPositionObj.diyIndexPage.originalVal + 'px';
|
||||
return data;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.cateList[0].category_name = this.$lang('components-diy.diy-index-page.home');
|
||||
},
|
||||
watch: {
|
||||
type(newVal, oldVal) {
|
||||
if (newVal) {
|
||||
this.getCategoryList();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getCategoryList();
|
||||
setTimeout(() => {
|
||||
// 获取组件的高度,默认高度为45(45是在375屏幕上的高度)
|
||||
const query = uni.createSelectorQuery();
|
||||
// #ifdef H5
|
||||
let cssSelect = '.page-header .u-navbar';
|
||||
this.top = 100;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP
|
||||
let cssSelect = '.page-header >>> .u-navbar';
|
||||
this.top = 20;
|
||||
// #endif
|
||||
|
||||
query
|
||||
.select(cssSelect)
|
||||
.boundingClientRect(data => {
|
||||
let height;
|
||||
if (this.diyGlobal.navBarSwitch) {
|
||||
height = data ? data.height : 45;
|
||||
} else {
|
||||
height = data ? data.height : 0;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
this.top += height * 2;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP
|
||||
this.top += height;
|
||||
// #endif
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
|
||||
this.setModuleLocationFn();
|
||||
},
|
||||
methods: {
|
||||
initPageIndex() {
|
||||
this.pageIndex = 0;
|
||||
this.showModuleFn();
|
||||
},
|
||||
//请求分类列表
|
||||
getCategoryList() {
|
||||
let url = '/api/goodscategory/tree';
|
||||
let data = {
|
||||
level: 3
|
||||
};
|
||||
this.$api.sendRequest({
|
||||
url: url,
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code >= 0) {
|
||||
let arr = [];
|
||||
let obj = {
|
||||
list: []
|
||||
};
|
||||
obj.category_name = this.value.title ? this.value.title : this.$lang('components-diy.diy-index-page.home');
|
||||
arr.push(obj);
|
||||
this.cateList = arr.concat(res.data);
|
||||
Object.keys(this.cateList).forEach((key, index) => {
|
||||
this.goodsList[key] = {
|
||||
page: 1,
|
||||
list: []
|
||||
};
|
||||
});
|
||||
this.twoCategorylist = this.cateList[this.pageIndex].child_list;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
//修改当前页面id
|
||||
changePageIndex(e) {
|
||||
this.isloading = true;
|
||||
this.pageIndex = e;
|
||||
this.showModuleFn();
|
||||
if (e == 0) return;
|
||||
this.twoCategorylist = this.cateList[this.pageIndex].child_list;
|
||||
if (this.cateList[this.pageIndex].child_list) {
|
||||
this.twoCategorylist = this.cateList[this.pageIndex].child_list;
|
||||
this.twoCategorylist.forEach(v => {
|
||||
if (v.image) {
|
||||
v.image = this.$util.img(v.image);
|
||||
} else {
|
||||
v.image = this.$util.getDefaultImage().goods;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.twoCategorylist = false;
|
||||
}
|
||||
|
||||
if (this.$refs.mescroll) {
|
||||
this.$refs.mescroll.refresh();
|
||||
this.$refs.mescroll.myScrollTo(0);
|
||||
}
|
||||
},
|
||||
|
||||
//监听二级分类 页面切换
|
||||
swiperTocategoryChange(e) {
|
||||
this.twoCategorylistId = e.detail.current;
|
||||
},
|
||||
|
||||
toDetail(item) {
|
||||
this.$util.redirectTo('/pages_goods/detail', {
|
||||
goods_id: item.goods_id
|
||||
});
|
||||
},
|
||||
getGoodsList(mescroll) {
|
||||
let id = this.pageIndex;
|
||||
var data = {
|
||||
page: mescroll.num,
|
||||
page_size: mescroll.size
|
||||
};
|
||||
data.category_id = this.cateList[this.pageIndex].category_id_1;
|
||||
data.category_level = 1;
|
||||
this.$api.sendRequest({
|
||||
url: '/api/goodssku/page',
|
||||
data: data,
|
||||
success: res => {
|
||||
this.isloading = false;
|
||||
let newArr = [];
|
||||
let msg = res.message;
|
||||
if (res.code == 0 && res.data) {
|
||||
this.count = res.data.count;
|
||||
newArr = res.data.list;
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
title: msg
|
||||
});
|
||||
}
|
||||
|
||||
mescroll.endSuccess(newArr.length);
|
||||
//设置列表数据
|
||||
if (mescroll.num == 1) this.goodsList[id].list = []; //如果是第一页需手动制空列表
|
||||
this.goodsList[id].list = this.goodsList[id].list.concat(newArr); //追加新数据
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
|
||||
this.$forceUpdate();
|
||||
}
|
||||
});
|
||||
},
|
||||
toCateGoodsList(e, f) {
|
||||
this.$util.redirectTo('/pages_goods/list', {
|
||||
category_id: e,
|
||||
category_level: f
|
||||
});
|
||||
},
|
||||
goodsImg(imgStr) {
|
||||
let imgs = imgStr.split(',');
|
||||
return imgs[0] ? this.$util.img(imgs[0], {
|
||||
size: 'mid'
|
||||
}) : this.$util.getDefaultImage().goods;
|
||||
},
|
||||
imgError(index) {
|
||||
this.goodsList[index].goods_image = this.$util.getDefaultImage().goods;
|
||||
},
|
||||
showPrice(data) {
|
||||
let price = data.discount_price;
|
||||
if (data.member_price && parseFloat(data.member_price) < parseFloat(price)) price = data.member_price;
|
||||
return price;
|
||||
},
|
||||
showMarketPrice(item) {
|
||||
if (item.market_price_show) {
|
||||
let price = this.showPrice(item);
|
||||
if (item.market_price > 0) {
|
||||
return item.market_price;
|
||||
} else if (parseFloat(item.price) > parseFloat(price)) {
|
||||
return item.price;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
goodsTag(data) {
|
||||
return data.label_name || '';
|
||||
},
|
||||
// 控制菜单展开关闭
|
||||
unfoldMenu() {
|
||||
if (this.isUnfold) this.$refs.navTopCategoryPop.open();
|
||||
else this.$refs.navTopCategoryPop.close();
|
||||
this.isUnfold = !this.isUnfold;
|
||||
},
|
||||
// 向vuex中的diyIndexPositionObj增加分类导航组件定位位置
|
||||
setModuleLocationFn() {
|
||||
const query = uni.createSelectorQuery().in(this);
|
||||
query.select('.nav-top-category')
|
||||
.boundingClientRect(data => {
|
||||
let diyIndexPage = {
|
||||
originalVal: data.height || 0, //自身高度 px
|
||||
moduleIndex: this.value.moduleIndex //组件在diy-group的位置
|
||||
};
|
||||
this.moduleHeight = (data.height || 0) + 'px';
|
||||
this.$store.commit('setDiyGroupPositionObj', {
|
||||
diyIndexPage: diyIndexPage
|
||||
});
|
||||
}).exec();
|
||||
},
|
||||
showModuleFn() {
|
||||
let searchModule = this.$root.diyData.value.filter((item, index) => {
|
||||
return item.componentName == 'Search';
|
||||
});
|
||||
// setDiyGroupShowModule值为【】表示显示所有组件,为【‘null’】则什么组件也不显示
|
||||
if (this.pageIndex == 0) this.$store.commit('setDiyGroupShowModule', JSON.stringify([]));
|
||||
else {
|
||||
if (searchModule[0].positionWay == 'fixed') this.$store.commit('setDiyGroupShowModule', JSON.stringify(
|
||||
['Search']));
|
||||
else this.$store.commit('setDiyGroupShowModule', JSON.stringify(['null']));
|
||||
}
|
||||
// 特殊处理,切换分类导航导致页面无法上下滚动
|
||||
// #ifdef H5
|
||||
if (this.pageIndex == 0) {
|
||||
// 标记当前页使用了mescroll (需延时,确保page已切换)
|
||||
setTimeout(function () {
|
||||
let uniPageDom = document.getElementsByTagName('uni-page')[0];
|
||||
uniPageDom && uniPageDom.removeAttribute('use_mescroll');
|
||||
}, 30);
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-top-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
transition: background 0.3s;
|
||||
width: 100%;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.text-fiexd {
|
||||
.text-con {
|
||||
line-height: 60rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.unfold-arrows {
|
||||
position: relative;
|
||||
margin-left: 4rpx;
|
||||
padding-left: 16rpx;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-topcategory-pop {
|
||||
padding: 0 10rpx 20rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
background-color: #fff;
|
||||
|
||||
.category-item {
|
||||
padding: 0 30rpx;
|
||||
height: 60rpx;
|
||||
text-align: center;
|
||||
line-height: 56rpx;
|
||||
border-radius: 40rpx;
|
||||
background-color: #f0f0f0;
|
||||
margin: 20rpx 10rpx 0;
|
||||
font-size: 24rpx;
|
||||
border: 2rpx solid transparent;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.active {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.diyIndex {
|
||||
width: 100%;
|
||||
height: 100rpx;
|
||||
white-space: nowrap;
|
||||
padding: 20rpx 0 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.widthAuto {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
margin-right: 40rpx;
|
||||
display: inline-block;
|
||||
line-height: 80rpx;
|
||||
font-size: $font-size-base;
|
||||
text-align: center;
|
||||
|
||||
.text-con {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
|
||||
&.active {
|
||||
font-size: $font-size-base;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.text-con.active {
|
||||
font-size: $font-size-base;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
left: calc(50% - 14rpx);
|
||||
width: 28rpx;
|
||||
height: 5rpx;
|
||||
border-radius: 5rpx;
|
||||
}
|
||||
|
||||
&.fill {
|
||||
border-radius: 50rpx;
|
||||
padding: 0 10rpx;
|
||||
|
||||
.text-con.active {
|
||||
font-size: $font-size-base;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.index-page-box {
|
||||
width: 100%;
|
||||
height: calc(100vh - 288rpx);
|
||||
}
|
||||
|
||||
.index-page-content {
|
||||
width: 100%;
|
||||
// height: calc(100vh - 144px);
|
||||
}
|
||||
|
||||
.index-category-box.active {
|
||||
padding-bottom: 160rpx;
|
||||
padding-bottom: calc(160rpx + constant(safe-area-inset-bottom));
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.index-category-box {
|
||||
width: 100%;
|
||||
padding-bottom: 110rpx;
|
||||
padding-bottom: calc(110rpx + constant(safe-area-inset-bottom));
|
||||
padding-bottom: calc(110rpx + env(safe-area-inset-bottom));
|
||||
|
||||
.twoCategorylist {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.twoCategory.min {
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
.twoCategory.big {
|
||||
height: 340rpx;
|
||||
}
|
||||
|
||||
.twoCategory {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 15rpx;
|
||||
overflow: hidden;
|
||||
margin-top: 20rpx;
|
||||
|
||||
.twoCategory-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.swiper-item {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
display: inline-block;
|
||||
margin-right: calc((100% - 120rpx * 5) / 4);
|
||||
overflow: hidden;
|
||||
|
||||
.item-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
image {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
view {
|
||||
width: 100%;
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
margin-top: 10rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper-item:nth-child(5n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.swiper-item:nth-child(10n + 6) {
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.dot-box {
|
||||
width: calc(100% - 40rpx);
|
||||
height: 50rpx;
|
||||
position: absolute;
|
||||
bottom: 0rpx;
|
||||
left: 20rpx;
|
||||
background: rgba($color: #000000, $alpha: 0);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.dot-item {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
background: #cccccc;
|
||||
border-radius: 6rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.dot-item.active {
|
||||
width: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.category_adv {
|
||||
width: 100%;
|
||||
margin: 20rpx 0;
|
||||
border-radius: 15rpx;
|
||||
}
|
||||
|
||||
.category-goods {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: 100%;
|
||||
height: 50rpx;
|
||||
margin-top: 100rpx;
|
||||
}
|
||||
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.goods-list.double-column {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: $margin-updown;
|
||||
|
||||
.goods-item {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
flex-basis: 48%;
|
||||
max-width: calc((100% - 30rpx) / 2);
|
||||
margin-right: $margin-both;
|
||||
margin-bottom: $margin-updown;
|
||||
border-radius: $border-radius;
|
||||
|
||||
&:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.goods-img {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-top: 100%;
|
||||
border-top-left-radius: $border-radius;
|
||||
border-top-right-radius: $border-radius;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.goods-tag {
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
padding: 8rpx 16rpx;
|
||||
position: absolute;
|
||||
border-bottom-right-radius: $border-radius;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: $font-size-goods-tag;
|
||||
}
|
||||
|
||||
.goods-tag-img {
|
||||
position: absolute;
|
||||
border-top-left-radius: $border-radius;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
padding: 0 26rpx 26rpx 26rpx;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
font-size: $font-size-base;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin-top: 20rpx;
|
||||
height: 68rpx;
|
||||
}
|
||||
|
||||
.discount-price {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin-top: 16rpx;
|
||||
|
||||
.unit {
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.pro-info {
|
||||
display: flex;
|
||||
margin-top: 16rpx;
|
||||
|
||||
.delete-price {
|
||||
text-decoration: line-through;
|
||||
flex: 1;
|
||||
|
||||
.unit {
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&>view {
|
||||
line-height: 1;
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.member-price-tag {
|
||||
display: inline-block;
|
||||
width: 60rpx;
|
||||
line-height: 1;
|
||||
margin-left: 6rpx;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-kefu" class="diy-kefu" :style="style">
|
||||
<view class="fui-list-group merchgroup" v-for="(item, index) in value.list" :key="index">
|
||||
<view class="fui-list jump" v-if="index == 0">
|
||||
<view class="fui-list-media">
|
||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="fui-list-inner">
|
||||
<view class="title" style="height: 75rpx;">
|
||||
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
||||
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 客服展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-kefu',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
previewSqs() {
|
||||
var img = this.$util.img(this.value.list[1].imageUrl)
|
||||
uni.previewImage({
|
||||
current: img,
|
||||
urls: [img]
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-listmenu" class="diy-listmenu" :style="style">
|
||||
<view class="fui-cell-group">
|
||||
<!-- <image mode="widthFix" style="width: 100%;" :src="$util.img(item.imageUrl)" show-menu-by-longpress></image> -->
|
||||
|
||||
<view v-for="(item, index) in value.list" @tap.stop="redirectTo(item.link)" class="fui-cell"
|
||||
:class="item.iconType == 'img' ? 'img-cell' : ''">
|
||||
<view class="fui-cell-icon" :style="{ 'color': item.style ? item.style.iconColor : '#333' }">
|
||||
<diy-icon v-if="item.iconType == 'icon'" :icon="item.icon" :value="item.style ? item.style : null"
|
||||
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
|
||||
<image v-if="item.iconType == 'img'" mode="widthFix" :src="$util.img(item.imageUrl)"
|
||||
style="border-radius:6rpx;width: 60rpx;"></image>
|
||||
</view>
|
||||
<view class="fui-cell-text" style="color:#333;">{{ item.title }}</view>
|
||||
<view class="fui-cell-remark" @tap.stop="redirectTo(item.link)" style="font-size: 24rpx;">{{ $lang('components-diy.diy-listmenu.view') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义列表菜单展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-listmenu',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.value.margin.top > 0) css += 'margin-top:' + this.value.margin.top * 2 + 'rpx;';
|
||||
return css;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
redirectTo(link) {
|
||||
if (link.wap_url) {
|
||||
if (this.$util.getCurrRoute() == this.$util.MEMBER_PAGE_URL && !this.storeToken) {
|
||||
this.redirectTologin(link.wap_url)
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.$util.diyRedirectTo(link);
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.img-cell {
|
||||
padding: 0 16rpx !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,257 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-live" type="banner" :loading="loading" :configs="skeletonConfig">
|
||||
<view class="live-wrap" @tap.stop="handlerClick(liveInfo.roomid)"
|
||||
v-if="liveInfo">
|
||||
<view class="banner-wrap">
|
||||
<image
|
||||
:src="liveInfo.banner != '' ? $util.img(liveInfo.banner) : $util.img('public/uniapp/live/live_default_banner.png')"
|
||||
mode="widthFix"
|
||||
@error="liveInfo.banner = $util.img('public/uniapp/live/live_default_banner.png')" />
|
||||
<view class="shade"></view>
|
||||
<view class="wrap">
|
||||
<view class="room-name">
|
||||
<text class="status-name font-size-base"
|
||||
:class="{ 'color-base-bg': liveInfo.live_status == '101' }">
|
||||
<text class="iconfont icon-zhibozhong font-size-sub"
|
||||
v-if="liveInfo.live_status == '101'"></text>
|
||||
<text class="iconfont icon-zhibojieshu font-size-sub" v-else></text>
|
||||
{{ liveInfo.status_name }}
|
||||
</text>
|
||||
{{ liveInfo.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="room-info" v-if="value.isShowAnchorInfo || value.isShowLiveGood">
|
||||
<block v-if="value.isShowAnchorInfo">
|
||||
<image
|
||||
:src="liveInfo.anchor_img != '' ? $util.img(liveInfo.anchor_img) : $util.getDefaultImage().head"
|
||||
class="anchor-img" @error="liveInfo.anchor_img = $util.getDefaultImage().head" show-menu-by-longpress/>
|
||||
<text class="anchor-name">{{ $lang('components-diy.diy-live.anchorPrefix') }}{{ liveInfo.anchor_name }}</text>
|
||||
</block>
|
||||
<text class="separate" v-if="value.isShowAnchorInfo && value.isShowLiveGood">|</text>
|
||||
<block v-if="value.isShowLiveGood">
|
||||
<text class="goods-text">{{ $lang('components-diy.diy-live.goodsPrefix') }}{{ liveInfo.goods.length }}</text>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 直播
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
components: {},
|
||||
name: 'diy-live',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
skeletonConfig: {
|
||||
headHeight: '200rpx'
|
||||
},
|
||||
liveInfo: {
|
||||
banner: '',
|
||||
anchor_img: ''
|
||||
}
|
||||
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getLiveInfo();
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getLiveInfo();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLiveInfo() {
|
||||
this.$api.sendRequest({
|
||||
url: '/live/api/live/info',
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.liveInfo = res.data;
|
||||
this.getLiveStatus();
|
||||
} else {
|
||||
this.liveInfo = null;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
entryRoom(roomId) {
|
||||
// #ifdef MP-WEIXIN
|
||||
wx.navigateTo({
|
||||
url: `plugin-private://wx2b03c6e691cd7370/pages/live-player-plugin?room_id=${roomId}`
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
getLiveStatus() {
|
||||
// #ifdef MP-WEIXIN
|
||||
let livePlayer = requirePlugin('live-player-plugin');
|
||||
|
||||
livePlayer.getLiveStatus({
|
||||
room_id: this.liveInfo.roomid
|
||||
}).then(res => {
|
||||
const liveStatus = res.liveStatus;
|
||||
if (liveStatus && liveStatus != this.liveInfo.live_status) {
|
||||
this.changeLiveStatus(liveStatus);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
});
|
||||
|
||||
// 往后间隔1分钟或更慢的频率去轮询获取直播状态
|
||||
var timer = setInterval(() => {
|
||||
livePlayer
|
||||
.getLiveStatus({
|
||||
room_id: this.liveInfo.roomid
|
||||
})
|
||||
.then(res => {
|
||||
const liveStatus = res.liveStatus;
|
||||
if (liveStatus && liveStatus != this.liveInfo.live_status) {
|
||||
this.changeLiveStatus(liveStatus);
|
||||
}
|
||||
if (this.$util.inArray(liveStatus, [103, 104, 106, 107])) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
});
|
||||
}, 60000);
|
||||
// #endif
|
||||
},
|
||||
changeLiveStatus(status) {
|
||||
this.$api.sendRequest({
|
||||
url: '/live/api/live/modifyLiveStatus',
|
||||
data: {
|
||||
room_id: this.liveInfo.roomid,
|
||||
status: status
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.getLiveInfo();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
async handlerClick(roomid) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'live-tap', data: roomid, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.entryRoom(roomid);
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.live-wrap {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shade {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: rgba($color: #888, $alpha: 0.3);
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 26rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.room-name {
|
||||
font-size: $font-size-toolbar;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.status-name {
|
||||
display: inline-block;
|
||||
font-size: $font-size-activity-tag;
|
||||
color: #fff;
|
||||
padding: 8rpx 12rpx;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 36rpx;
|
||||
margin-right: 20rpx;
|
||||
|
||||
.icon-zhibozhong {
|
||||
font-size: $font-size-activity-tag;
|
||||
color: #fff;
|
||||
margin-right: 9rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-info {
|
||||
padding: 20rpx 30rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
|
||||
.anchor-img {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.anchor-name,
|
||||
.goods-text {
|
||||
font-size: $font-size-base;
|
||||
line-height: 60rpx;
|
||||
}
|
||||
|
||||
.separate {
|
||||
color: #808080;
|
||||
margin: 0 10rpx;
|
||||
line-height: 56rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.coupon-all>>>.uni-scroll-view::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,260 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-many-goods-list" class="many-goods-list">
|
||||
<scroll-view scroll-x="true" class="many-goods-list-head" :scroll-into-view="'a' + cateIndex"
|
||||
:style="manyWrapCss">
|
||||
<view v-for="(item, index) in value.list" class="scroll-item" :class="{ active: index == cateIndex }"
|
||||
:id="'a' + index" :key="index" @tap.stop="handlerClick({ item, index })">
|
||||
<view class="split-line" v-if="index > 0"></view>
|
||||
<view class="cate">
|
||||
<view class="name" :style="{ color: value.headStyle.titleColor }">{{ item.title }}</view>
|
||||
<view class="desc" :class="{ 'color-base-bg': index == cateIndex && item.desc }">{{ item.desc }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="many-goods-list-fill" :style="{ 'height': manyInfo.height }" v-if="fixedTop"></view>
|
||||
<diy-goods-list class="many-goods-list-body" v-if="goodsValue" :value="goodsValue"
|
||||
ref="diyGoodsList"></diy-goods-list>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-many-goods-list',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
scrollTop: {
|
||||
type: [Number, String]
|
||||
},
|
||||
global: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cateIndex: 0, // 当前选中的分类id
|
||||
goodsValue: null, // 商品列表数据
|
||||
manyInfo: {
|
||||
bodyHeight: 0,
|
||||
bodyTop: 0,
|
||||
height: 0,
|
||||
top: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.changeCateIndex(this.value.list[0], 0, true);
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.changeCateIndex(this.value.list[0], 0, true);
|
||||
},
|
||||
scrollTop: function (nval) {
|
||||
const query = uni.createSelectorQuery().in(this);
|
||||
query
|
||||
.select('.many-goods-list')
|
||||
.boundingClientRect(data => {
|
||||
if (data) {
|
||||
this.manyInfo.top = data.top;
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
|
||||
query
|
||||
.select('.many-goods-list .many-goods-list-body')
|
||||
.boundingClientRect(data => {
|
||||
if (data) {
|
||||
this.manyInfo.bodyHeight = (data.height || 0);
|
||||
this.manyInfo.bodyTop = (data.top || 0);
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fixedTop() {
|
||||
let diyPositionObj = JSON.parse(JSON.stringify(this.$store.state.diyGroupPositionObj));
|
||||
let positionHeight = 0;
|
||||
let height = 0;
|
||||
|
||||
delete diyPositionObj.diyManyGoodsList;
|
||||
|
||||
if (diyPositionObj) {
|
||||
let arr = Object.values(diyPositionObj);
|
||||
arr.forEach((item, index) => {
|
||||
positionHeight += item.originalVal; //定位的高度【搜索框+导航分类+自定义头部】
|
||||
});
|
||||
}
|
||||
|
||||
if (this.manyInfo.top < positionHeight && (this.manyInfo.bodyTop + this.manyInfo.bodyHeight >
|
||||
positionHeight + Number.parseFloat(this.manyInfo.height))) {
|
||||
height = positionHeight;
|
||||
}
|
||||
return height;
|
||||
},
|
||||
manyWrapCss() {
|
||||
let html = '';
|
||||
html += `position: ${this.fixedTop ? 'fixed' : 'initial'};`
|
||||
html += `top: ${this.fixedTop}px;`
|
||||
if (!this.global.topNavBg)
|
||||
html += `background-color: #fff;`
|
||||
else
|
||||
html += `background-color: ${this.fixedTop ? this.global.topNavColor : 'transparent'};`
|
||||
return html;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const query = uni.createSelectorQuery().in(this);
|
||||
query
|
||||
.select('.many-goods-list .many-goods-list-head')
|
||||
.boundingClientRect(data => {
|
||||
if (data) {
|
||||
this.manyInfo.height = (data.height || 0) + 'px';
|
||||
// 向vuex中的diyIndexPositionObj增加多商品组件定位位置
|
||||
let diyManyGoodsList = {
|
||||
originalVal: data.height || 0 //自身高度 px
|
||||
}
|
||||
this.$store.commit('setDiyGroupPositionObj', {
|
||||
diyManyGoodsList: diyManyGoodsList
|
||||
});
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
},
|
||||
methods: {
|
||||
changeCateIndex(item, index, isFirst) {
|
||||
this.cateIndex = index;
|
||||
this.goodsValue = {
|
||||
sources: item.sources,
|
||||
categoryId: item.categoryId,
|
||||
categoryName: item.categoryName,
|
||||
goodsId: item.goodsId,
|
||||
componentBgColor: this.value.componentBgColor,
|
||||
componentAngle: this.value.componentAngle,
|
||||
topAroundRadius: this.value.topAroundRadius,
|
||||
bottomAroundRadius: this.value.bottomAroundRadius,
|
||||
elementBgColor: this.value.elementBgColor,
|
||||
elementAngle: this.value.elementAngle,
|
||||
topElementAroundRadius: this.value.topElementAroundRadius,
|
||||
bottomElementAroundRadius: this.value.bottomElementAroundRadius,
|
||||
count: this.value.count,
|
||||
nameLineMode: this.value.nameLineMode,
|
||||
template: this.value.template,
|
||||
style: this.value.style,
|
||||
ornament: this.value.ornament,
|
||||
sortWay: this.value.sortWay,
|
||||
saleStyle: this.value.saleStyle,
|
||||
tag: this.value.tag,
|
||||
btnStyle: this.value.btnStyle,
|
||||
goodsNameStyle: this.value.goodsNameStyle,
|
||||
theme: this.value.theme,
|
||||
priceStyle: this.value.priceStyle,
|
||||
slideMode: this.value.slideMode,
|
||||
imgAroundRadius: this.value.imgAroundRadius,
|
||||
margin: this.value.margin,
|
||||
goodsMarginType: this.value.goodsMarginType,
|
||||
goodsMarginNum: this.value.goodsMarginNum
|
||||
};
|
||||
|
||||
// 如果是第一次加载,不需要执行下面代码
|
||||
if (isFirst) return;
|
||||
this.$refs.diyGoodsList.goodsValue = this.goodsValue;
|
||||
this.$refs.diyGoodsList.getGoodsList();
|
||||
},
|
||||
async handlerClick({ item, index }) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'many-goods-list-tap', data: { item, index }, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.changeCateIndex(item, index, false);
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.many-goods-list-head {
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
scroll-view {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx 0;
|
||||
|
||||
.scroll-item {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
width: calc(25% - 40rpx);
|
||||
position: relative;
|
||||
padding: 0 20rpx;
|
||||
|
||||
&:first-child {
|
||||
width: calc(25% - 20rpx);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.split-line {
|
||||
display: inline-block;
|
||||
width: 1rpx;
|
||||
height: 30rpx;
|
||||
background-color: #e5e5e5;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&.active {
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 32rpx;
|
||||
color: $color-title;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cate {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: $font-size-tag;
|
||||
color: $color-tip;
|
||||
height: 36rpx;
|
||||
line-height: 36rpx;
|
||||
margin-top: 10rpx;
|
||||
min-width: 120rpx;
|
||||
max-width: 220rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-map" class="diy-map" :style="style">
|
||||
<view class="fui-list-group merchgroup" style="margin-top:0" v-for="(item, index) in value.list">
|
||||
<map id="map" style="width: 100%; height:600rpx" scale="12" :markers="markerst" bindupdated="bindupdated"
|
||||
:longitude="item.lng" :latitude="item.lat" show-location>
|
||||
<!-- <cover-view
|
||||
style="position:absolute;right:10px;bottom:30rpx;z-index:99999;background:#4390FF;padding:5px 10px;wxcs_style_padding:10rpx 20rpx;border-radius:8rpx;color: #fff;"
|
||||
@tap.stop="handlerClick(item)" @tap="handlerClick(item)">
|
||||
<cover-view style="font-size:24rpx">一键导航</cover-view>
|
||||
</cover-view> -->
|
||||
|
||||
<!-- 使用非原生cover-view, 解决原生cover-view组件渲染机制z-index失效的问题 -->
|
||||
<div
|
||||
style="position:absolute;right:12rpx;bottom:48rpx;z-index:1;background:#4390FF;padding:0rpx 20rpx;border-radius:8rpx;color: #fff;"
|
||||
@tap.stop="handlerClick(item)">
|
||||
<span style="font-size:24rpx;color: #fff;">{{ $lang('components-diy.diy-map.navigate') }}</span>
|
||||
</div>
|
||||
</map>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 地图
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-map',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
markers: []
|
||||
};
|
||||
},
|
||||
created() {
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
markerst() {
|
||||
return [{
|
||||
id: 1,
|
||||
latitude: this.value.list[0].lat,
|
||||
longitude: this.value.list[0].lng
|
||||
}]
|
||||
},
|
||||
style() {
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
tomap(item) {
|
||||
uni.openLocation({
|
||||
latitude: parseFloat(item.lat),
|
||||
longitude: parseFloat(item.lng),
|
||||
name: this.$lang('components-diy.diy-map.navigate'),
|
||||
})
|
||||
},
|
||||
async handlerClick(item) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'map-tap', data: item, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.tomap(item);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,279 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-member-my-order" class="common-wrap" :style="warpCss">
|
||||
<view class="order-wrap">
|
||||
<view class="status-wrap">
|
||||
<view class="item-wrap" @tap.stop="redirect('/pages_order/list?status=waitpay')"
|
||||
style="margin-right: 10rpx;">
|
||||
<view class="icon-block">
|
||||
<template v-if="value.style == 3">
|
||||
<image :src="$util.img('public/uniapp/member/order/wait_pay.png')" mode="widthFix" show-menu-by-longpress/>
|
||||
<view class="icon-shade"
|
||||
:style="'-webkit-mask-image: url(' + $util.img('public/uniapp/member/order/wait_pay_shade.png') + ')'">
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<diy-icon :icon="value.icon.waitPay.icon" v-if="value.icon.waitPay"
|
||||
:value="value.icon.waitPay.style ? value.icon.waitPay.style : null"></diy-icon>
|
||||
</template>
|
||||
<text v-if="orderNum.waitpay > 0" class="order-num color-base-bg price-font">{{ orderNum.waitpay
|
||||
> 99 ? '99+' :
|
||||
orderNum.waitpay }}</text>
|
||||
</view>
|
||||
<view class="title">{{ $lang('waitpay') }}</view>
|
||||
</view>
|
||||
<view class="item-wrap" @tap.stop="redirect('/pages_order/list?status=waitsend')"
|
||||
style="margin-right: 10rpx;">
|
||||
<view class="icon-block">
|
||||
<template v-if="value.style == 3">
|
||||
<image :src="$util.img('public/uniapp/member/order/wait_send.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||
<view class="icon-shade"
|
||||
:style="'-webkit-mask-image: url(' + $util.img('public/uniapp/member/order/wait_send_shade.png') + ')'">
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<diy-icon :icon="value.icon.waitSend.icon" v-if="value.icon.waitSend"
|
||||
:value="value.icon.waitSend.style ? value.icon.waitSend.style : null"></diy-icon>
|
||||
</template>
|
||||
<text v-if="orderNum.waitsend > 0" class="order-num color-base-bg price-font">{{
|
||||
orderNum.waitsend > 99 ? '99+'
|
||||
: orderNum.waitsend }}</text>
|
||||
</view>
|
||||
<view class="title">{{ $lang('waitsend') }}</view>
|
||||
</view>
|
||||
<view class="item-wrap" @tap.stop="redirect('/pages_order/list?status=waitconfirm')"
|
||||
style="margin-right: 10rpx;">
|
||||
<view class="icon-block">
|
||||
<template v-if="value.style == 3">
|
||||
<image :src="$util.img('public/uniapp/member/order/wait_confirm.png')" mode="widthFix" show-menu-by-longpress/>
|
||||
<view class="icon-shade"
|
||||
:style="'-webkit-mask-image: url(' + $util.img('public/uniapp/member/order/wait_confirm_shade.png') + ')'">
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<diy-icon :icon="value.icon.waitConfirm.icon" v-if="value.icon.waitConfirm"
|
||||
:value="value.icon.waitConfirm.style ? value.icon.waitConfirm.style : null"></diy-icon>
|
||||
</template>
|
||||
<text v-if="orderNum.waitconfirm > 0" class="order-num color-base-bg price-font">{{
|
||||
orderNum.waitconfirm > 99 ?
|
||||
'99+' : orderNum.waitconfirm }}</text>
|
||||
</view>
|
||||
<view class="title">{{ $lang('waitconfirm') }}</view>
|
||||
</view>
|
||||
<view class="item-wrap" @tap.stop="redirect('/pages_order/list?status=waitrate')"
|
||||
style="margin-right: 10rpx;">
|
||||
<view class="icon-block">
|
||||
<template v-if="value.style == 3">
|
||||
<image :src="$util.img('public/uniapp/member/order/wait_use.png')" mode="widthFix" show-menu-by-longpress/>
|
||||
<view class="icon-shade"
|
||||
:style="'-webkit-mask-image: url(' + $util.img('public/uniapp/member/order/wait_rate_shade.png') + ')'">
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<diy-icon :icon="value.icon.waitUse.icon" v-if="value.icon.waitUse"
|
||||
:value="value.icon.waitUse.style ? value.icon.waitUse.style : null"></diy-icon>
|
||||
</template>
|
||||
<!-- <text v-if="orderNum.wait_use > 0" class="order-num color-base-bg price-font">{{ orderNum.wait_use > 99 ? '99+' : orderNum.wait_use }}</text> -->
|
||||
</view>
|
||||
<view class="title">{{ $lang('completed') }}</view>
|
||||
</view>
|
||||
<view class="item-wrap" @tap.stop="redirect('/pages_tool/order/activist')">
|
||||
<view class="icon-block">
|
||||
<template v-if="value.style == 3">
|
||||
<image :src="$util.img('public/uniapp/member/order/refunding.png')" mode="widthFix" show-menu-by-longpress/>
|
||||
<view class="icon-shade"
|
||||
:style="'-webkit-mask-image: url(' + $util.img('public/uniapp/member/order/refunding_shade.png') + ')'">
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<diy-icon :icon="value.icon.refunding.icon" v-if="value.icon.refunding"
|
||||
:value="value.icon.refunding.style ? value.icon.refunding.style : null"></diy-icon>
|
||||
</template>
|
||||
<text v-if="orderNum.refunding > 0" class="order-num color-base-bg price-font">{{
|
||||
orderNum.refunding > 99 ?
|
||||
'99+' : orderNum.refunding }}</text>
|
||||
</view>
|
||||
<view class="title">{{ $lang('activist') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义会员中心——我的订单展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-member-my-order',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
orderNum: {
|
||||
waitpay: 0,
|
||||
waitsend: 0,
|
||||
waitconfirm: 0,
|
||||
refunding: 0,
|
||||
wait_use: 0,
|
||||
waitrate: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.init();
|
||||
},
|
||||
watch: {
|
||||
storeToken(nVal, oVal) {
|
||||
this.init();
|
||||
},
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.init();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
if (this.storeToken) {
|
||||
this.getOrderNum();
|
||||
} else {
|
||||
this.orderNum = {
|
||||
waitpay: 0,
|
||||
waitsend: 0,
|
||||
waitconfirm: 0,
|
||||
refunding: 0,
|
||||
wait_use: 0,
|
||||
waitrate: 0
|
||||
};
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取订单数量
|
||||
*/
|
||||
getOrderNum() {
|
||||
this.$api.sendRequest({
|
||||
url: '/api/order/num',
|
||||
data: {
|
||||
order_status: 'waitpay,waitsend,waitconfirm,refunding,wait_use,waitrate'
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.orderNum = res.data;
|
||||
} else {
|
||||
this.orderNum = {
|
||||
waitpay: 0,
|
||||
waitsend: 0,
|
||||
waitconfirm: 0,
|
||||
refunding: 0,
|
||||
wait_use: 0,
|
||||
waitrate: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 跳转
|
||||
* @param {Object} url
|
||||
*/
|
||||
redirect(url) {
|
||||
if (this.storeToken) {
|
||||
this.$util.redirectTo(url);
|
||||
} else {
|
||||
this.$refs.login.open(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.common-wrap {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-wrap {
|
||||
.status-wrap {
|
||||
display: flex;
|
||||
padding: 30rpx 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-wrap {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
background: #f6f7f9;
|
||||
padding: 20rpx 0;
|
||||
|
||||
.icon-block {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
font-size: 60rpx;
|
||||
margin: 4rpx auto;
|
||||
position: relative;
|
||||
|
||||
&>image {
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
right: 5%;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.icon-shade {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 5%;
|
||||
right: 5%;
|
||||
background: $base-color;
|
||||
-webkit-mask: no-repeat center / contain;
|
||||
}
|
||||
}
|
||||
|
||||
.order-num {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
box-sizing: border-box;
|
||||
color: #ffffff;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
padding: 0 6rpx;
|
||||
min-width: 30rpx;
|
||||
border-radius: 16rpx;
|
||||
height: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,364 +0,0 @@
|
||||
<template>
|
||||
<view class="diy-merch-list">
|
||||
<x-skeleton type="list" :loading="loading" :configs="skeletonConfig" v-if="value.ornament.type == 'default'">
|
||||
<view class="merch-wrap" :style="warpCss">
|
||||
<view :class="['list-wrap', value.style]" :style="warpCss">
|
||||
<view :class="['item', value.ornament.type]" v-for="(item, index) in list" :key="index"
|
||||
:style="itemCss" @tap.stop="handlerClick(item)">
|
||||
<view class="merch-img" @tap.stop="handlerClick(item)">
|
||||
<image class="cover-img" :src="$util.img(item.merch_image)" mode="widthFix"
|
||||
@error="imgError(index)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc">{{ item.desc }}</text>
|
||||
<view class="read-wrap"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
<view v-else :style="warpCss">
|
||||
<scroll-view :scroll-x="true" :class="['merch-nav', 'singleSlide']">
|
||||
<!-- #ifdef MP -->
|
||||
<view class="uni-scroll-view-content">
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="merch-nav-item graphic" v-for="(item, index) in list" :key="index"
|
||||
:style="{ width: 100 / 4 + '%' }" @tap.stop="handlerClick(item)">
|
||||
<view class="graphic-img" v-if="value.mode != 'text'"
|
||||
:style="{ fontSize: value.imageSize * 2 + 'rpx', width: value.imageSize * 2 + 'rpx', height: value.imageSize * 2 + 'rpx' }"
|
||||
@tap.stop="handlerClick(item)">
|
||||
<image
|
||||
:src="$util.img(item.merch_image) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
mode="aspectFill" show-menu-by-longpress
|
||||
style="max-width: 80rpx; max-height: 80rpx; border-radius: 50rpx;" />
|
||||
</view>
|
||||
<text class="graphic-text"
|
||||
style="font-size: 28rpx; font-weight: normal; color: rgb(48, 49, 51);"
|
||||
@tap.stop="handlerClick(item)">
|
||||
{{ item.merch_name }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- #ifdef MP -->
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 文章
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-article',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
list: [
|
||||
/*{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
},
|
||||
{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
},
|
||||
{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
},
|
||||
{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
},
|
||||
{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
},
|
||||
{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
},
|
||||
{
|
||||
merch_name:'品牌',
|
||||
merch_image:'https://saas.cn//upload/4/common/images/20240824/20240824040223172448654356147.jpg'
|
||||
}*/
|
||||
],
|
||||
loading: true,
|
||||
skeletonConfig: {
|
||||
gridRows: 1,
|
||||
gridRowsGap: '40rpx',
|
||||
headWidth: '160rpx',
|
||||
headHeight: '160rpx',
|
||||
textRows: 2
|
||||
}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getList();
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 子项样式
|
||||
itemCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.value.ornament.type == 'shadow') {
|
||||
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color;
|
||||
}
|
||||
if (this.value.ornament.type == 'stroke') {
|
||||
obj += 'border:' + '2rpx solid ' + this.value.ornament.color;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
var data = {
|
||||
num: this.value.count
|
||||
};
|
||||
if (this.value.sources == 'diy') {
|
||||
data.num = 0;
|
||||
data.merch_id_arr = this.value.merchIds.toString();
|
||||
}
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/merch/api/merch/lists',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
let data = res.data;
|
||||
this.list = data;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
toDetail(item) {
|
||||
this.$util.redirectTo('/pages_promotion/merch/detail', {
|
||||
merch_id: item.merch_id
|
||||
});
|
||||
},
|
||||
imgError(index) {
|
||||
if (this.list[index]) this.list[index].merch_image = this.$util.getDefaultImage().article;
|
||||
},
|
||||
async handlerClick(item) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'merch-list-tap', data: item, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.toDetail(item);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
/* 单行滑动 */
|
||||
.merch-nav.singleSlide>>>.uni-scroll-view-content {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.merch-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.singleSlide {
|
||||
.merch-nav-item {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.pageSlide {
|
||||
position: relative;
|
||||
|
||||
.merch-nav-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.merch-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 14rpx 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.graphic-text {
|
||||
padding-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
&.alone {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.text {
|
||||
|
||||
.graphic-text {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.graphic-img {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
font-size: 24rpx;
|
||||
|
||||
.tag {
|
||||
position: absolute;
|
||||
top: -10rpx;
|
||||
right: -24rpx;
|
||||
color: #fff;
|
||||
border-radius: 24rpx;
|
||||
border-bottom-left-radius: 0;
|
||||
transform: scale(0.8);
|
||||
padding: 8rpx 16rpx;
|
||||
line-height: 1;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 50rpx;
|
||||
color: $color-sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.merch-wrap {
|
||||
.list-wrap {
|
||||
&.style-1 {
|
||||
.item {
|
||||
display: flex;
|
||||
padding: 20rpx;
|
||||
margin-top: 24rpx;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.merch-img {
|
||||
margin-right: 20rpx;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
|
||||
// flex: 1;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// justify-content: space-between;
|
||||
.desc {
|
||||
color: #888
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
// margin-bottom: 10rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.abstract {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
|
||||
.read-wrap {
|
||||
display: flex;
|
||||
color: #999ca7;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 10rpx;
|
||||
line-height: 1;
|
||||
|
||||
text {
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 36rpx;
|
||||
vertical-align: bottom;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: $base-color;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,369 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-notes" type="list" :loading="loading" :configs="skeletonConfig">
|
||||
<view class="diy-notes" :style="{ backgroundColor: value.componentBgColor }">
|
||||
<view class="diy-notes-top">
|
||||
<view class="notes-title" :style="{ color: value.titleTextColor }">{{ value.title }}</view>
|
||||
<view class="notes-more" @tap.stop="toMore()" :style="{ color: value.moreTextColor }">{{ value.more }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="diy-notes-box" scroll-x="true" show-scrollbar="true">
|
||||
<view class="notes-box-item" v-for="(item, i) in dataList" :key="i" @tap.stop="handlerClick(item)" :style="notesItemStyle">
|
||||
<view class="notes-item" v-if="item.status == 1">
|
||||
<view class="notes-item-con">
|
||||
<view class="notes-title">{{ item.note_title }}</view>
|
||||
<view class="notes-highlights-list" v-if="value.notesLabel == 1 && item.goods_highlights">
|
||||
<text class="color-base-bg" v-for="(labelItem, labelIndex) in item.label"
|
||||
:key="labelIndex">{{ labelItem }}</text>
|
||||
</view>
|
||||
<view class="notes-intro">
|
||||
<text class="notes-label color-base-text">#{{ item.note_type == 'goods_item' ? '单品介绍' :
|
||||
'掌柜说' }}#</text>
|
||||
{{ item.note_abstract }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="notes-img-wrap" :class="{ 'notes-img-wrap-list': item.cover_type == 1 }">
|
||||
<image v-if="item.cover_type == 0" :src="$util.img(item.img)" @error="imageError(i)"
|
||||
mode="aspectFill" class="notes-item-image" show-menu-by-longpress/>
|
||||
<image v-else v-for="(imgItem, imgIndex) in item.img" :key="imgIndex"
|
||||
:src="$util.img(imgItem)" @error="imageError(i)" mode="aspectFit"
|
||||
class="notes-item-image-li" show-menu-by-longpress/>
|
||||
</view>
|
||||
|
||||
<view class="notes-item-con">
|
||||
<view class="notes-info"
|
||||
v-if="(value.readNum == 1 && item.is_show_read_num == 1) || (value.uploadTime == 1 && item.is_show_release_time == 1)">
|
||||
<view class="notes-num">
|
||||
<text v-show="value.uploadTime == 1 && item.is_show_release_time == 1">{{
|
||||
item.update_time_day }}</text>
|
||||
</view>
|
||||
<view class="notes-num">
|
||||
<text v-show="value.readNum == 1 && item.is_show_read_num == 1">{{ $lang('components-diy.diy-notes.readPrefix') }} {{
|
||||
item.initial_read_num + item.read_num }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-notes',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
skeletonConfig: {
|
||||
itemDirection: 'column',
|
||||
headWidth: '100%',
|
||||
headHeight: '320rpx',
|
||||
textRows: 2,
|
||||
textWidth: ['100%', '80%']
|
||||
},
|
||||
dataList: [],
|
||||
giveLikeFlag: false
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getDataList();
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getDataList();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
notesItemStyle() {
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
this.getDataList();
|
||||
},
|
||||
getDataList() {
|
||||
var data = {
|
||||
num: this.value.count
|
||||
};
|
||||
|
||||
if (this.value.sources == 'diy') {
|
||||
data.num = 0;
|
||||
data.note_id_arr = this.value.noteId.toString();
|
||||
}
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/notes/api/notes/lists',
|
||||
data: data,
|
||||
success: res => {
|
||||
var data = res.data;
|
||||
this.dataList = [];
|
||||
if (data) {
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var item = {};
|
||||
item = data[i];
|
||||
if (data[i].cover_type == 1) {
|
||||
item.img = data[i].cover_img.split(',');
|
||||
} else {
|
||||
item.img = data[i].cover_img;
|
||||
}
|
||||
|
||||
if (data[i].upload_time) {
|
||||
item.update_time_day = this.$util.timeStampTurnTime(data[i].upload_time)
|
||||
.split(' ')[0];
|
||||
} else {
|
||||
item.update_time_day = this.$util.timeStampTurnTime(data[i].create_time)
|
||||
.split(' ')[0];
|
||||
}
|
||||
|
||||
item.label = data[i].goods_highlights.split(',');
|
||||
this.dataList.push(item);
|
||||
}
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
toMore() {
|
||||
this.$util.redirectTo('/pages_tool/store_notes/note_list');
|
||||
},
|
||||
toDetail(id) {
|
||||
this.$util.redirectTo('/pages_tool/store_notes/note_detail', {
|
||||
note_id: id
|
||||
});
|
||||
},
|
||||
/* 点赞 */
|
||||
giveLike(noteId, index) {
|
||||
if (!this.storeToken) {
|
||||
this.$refs.login.open(this.$util.INDEX_PAGE_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.giveLikeFlag) return false;
|
||||
this.giveLikeFlag = true;
|
||||
|
||||
var url = this.dataList[index].is_dianzan == 1 ? '/notes/api/record/delete' : '/notes/api/record/add';
|
||||
this.$api.sendRequest({
|
||||
url: url,
|
||||
data: {
|
||||
note_id: noteId
|
||||
},
|
||||
success: res => {
|
||||
this.giveLikeFlag = false;
|
||||
if (res.code == 0 && res.data > 0) {
|
||||
if (this.noteType != 'goods_item')
|
||||
this.dataList[index].dianzan_num = this.dataList[index].is_dianzan == 1 ? this.dataList[index].dianzan_num - 1 : this.dataList[index].dianzan_num + 1;
|
||||
else {
|
||||
this.dataList[index].dianzan_num = this.dataList[index].is_dianzan == 1 ? this.dataList[index].dianzan_num - 1 : this.dataList[index].dianzan_num + 1;
|
||||
}
|
||||
this.dataList[index].is_dianzan = this.dataList[index].is_dianzan == 1 ? 0 : 1;
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
title: res.message
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
imageError(index) {
|
||||
this.dataList[index].img = this.$util.getDefaultImage().goods;
|
||||
this.$forceUpdate();
|
||||
},
|
||||
async handlerClick(item) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'notes-tap', data: item, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
this.toDetail(item.note_id);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
scroll-view ::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.diy-notes {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.diy-notes-top {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
.notes-title {
|
||||
width: 70%;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notes-more {
|
||||
font-size: $font-size-tag;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.notes-more::after {
|
||||
font-family: 'iconfont';
|
||||
content: '\e6a3';
|
||||
font-size: $font-size-tag;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.diy-notes-box {
|
||||
width: 100%;
|
||||
padding: 30rpx 0 20rpx;
|
||||
}
|
||||
|
||||
.notes-box-item {
|
||||
width: calc(100% - 8rpx);
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
margin-bottom: 30rpx;
|
||||
border-radius: 10rpx;
|
||||
overflow: hidden;
|
||||
-moz-box-shadow: 0 0 20rpx rgba(0, 0, 0, 0.02);
|
||||
-webkit-box-shadow: 0 0 20rpx rgba(0, 0, 0, 0.02);
|
||||
box-shadow: 0 0 20rpx rgba(0, 0, 0, 0.02);
|
||||
|
||||
.notes-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.notes-img-wrap {
|
||||
position: relative;
|
||||
height: 300rpx;
|
||||
|
||||
.notes-item-image {
|
||||
width: 100%;
|
||||
height: 300rpx;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.notes-label {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
bottom: 20rpx;
|
||||
max-width: calc(100vh - 40rpx);
|
||||
background-color: #ffffff;
|
||||
line-height: 36rpx;
|
||||
padding: 0 10rpx 0 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.notes-img-wrap-list {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
|
||||
image {
|
||||
width: calc((100% - 40rpx) / 3);
|
||||
height: 210rpx;
|
||||
margin-top: 20rpx;
|
||||
|
||||
&:nth-child(-n + 3) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
width: calc((100% - 40rpx) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
.notes-item-con {
|
||||
.notes-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 44rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notes-highlights-list {
|
||||
text {
|
||||
display: inline-block;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 36rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: 4rpx;
|
||||
margin: 0 5rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.notes-intro {
|
||||
margin: 4rpx 0 8rpx;
|
||||
line-height: 40rpx;
|
||||
overflow: hidden;
|
||||
|
||||
text {
|
||||
float: left;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.notes-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20rpx;
|
||||
|
||||
.notes-num {
|
||||
>text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: $font-size-tag;
|
||||
color: #969799;
|
||||
|
||||
text.iconfont {
|
||||
font-size: 26rpx;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,311 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-notice" class="diy-notice">
|
||||
<view :class="['notice', value.contentStyle]" :style="noticeWrapCss">
|
||||
|
||||
<image v-if="value.iconType == 'img'" class="notice-img" :src="$util.img(value.imageUrl)"
|
||||
mode="heightFix" />
|
||||
<diy-icon v-if="value.iconType == 'icon'" :icon="value.icon" :value="value.style ? value.style : 'null'"
|
||||
:style="{ maxWidth: 30 * 2 + 'rpx', maxHeight: 30 * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
|
||||
<view class="notice-xian"></view>
|
||||
<view class="main-wrap">
|
||||
<!-- 横向滚动 -->
|
||||
<view class="horizontal-wrap" v-if="value.scrollWay == 'horizontal'"
|
||||
@tap="toLink(list[0])">
|
||||
<view class="marquee-wrap">
|
||||
<view class="marquee" :style="marqueeStyle">
|
||||
<text v-for="(item, index) in list" :key="index"
|
||||
:style="{ color: value.textColor, fontSize: value.fontSize * 2 + 'rpx', fontWeight: value.fontWeight }">{{
|
||||
item.title }}</text>
|
||||
</view>
|
||||
<view class="marquee" :style="marqueeAgainStyle">
|
||||
<text v-for="(item, index) in list" :key="index"
|
||||
:style="{ color: value.textColor, fontSize: value.fontSize * 2 + 'rpx', fontWeight: value.fontWeight }">{{
|
||||
item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上下滚动 -->
|
||||
<template v-if="value.scrollWay == 'upDown'">
|
||||
<swiper :vertical="true" :duration="500" autoplay="true" circular="true">
|
||||
<swiper-item v-for="(item, index) in list" :key="index"
|
||||
@touchmove.prevent.stop @tap="toLink(item)">
|
||||
<text class="beyond-hiding using-hidden"
|
||||
:style="{ color: value.textColor, fontSize: value.fontSize * 2 + 'rpx', fontWeight: value.fontWeight }">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view @touchmove.prevent.stop>
|
||||
<uni-popup ref="noticePopup" type="center">
|
||||
<view class="notice-popup">
|
||||
<view class="head-wrap" @tap.stop="closeNoticePopup">
|
||||
<text>{{ $lang('components-diy.diy-notice.notice') }}</text>
|
||||
<text class="iconfont icon-close"></text>
|
||||
</view>
|
||||
<view class="content-wrap">{{ notice }}</view>
|
||||
<button type="primary" @tap.stop="closeNoticePopup">{{ $lang('components-diy.diy-notice.gotIt') }}</button>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
// 公告展示
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-notice',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
list: [],
|
||||
notice: '', // 当前点击的弹框内容
|
||||
marqueeWrapWidth: 0, // 容器宽度
|
||||
marqueeWidth: 0, // 公告内容累加宽度
|
||||
marqueeStyle: '', // 横向滚动样式
|
||||
marqueeAgainStyle: '', // 横向滚动复制样式
|
||||
time: 0, // 滚动完成时间
|
||||
delayTime: 1000 // 动画延迟时间
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
if (this.value.sources == 'initial') this.getData();
|
||||
}
|
||||
},
|
||||
created() { },
|
||||
mounted() {
|
||||
// 数据源:公告系统
|
||||
if (this.value.sources == 'initial') {
|
||||
this.getData();
|
||||
} else {
|
||||
this.list = this.value.list;
|
||||
this.bindCrossSlipEvent();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
noticeWrapCss: function () {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getData() {
|
||||
var data = {
|
||||
page_size: 0
|
||||
};
|
||||
|
||||
if (this.value.sources == 'initial') {
|
||||
data.page_size = this.value.count;
|
||||
}
|
||||
|
||||
if (this.value.noticeIds.length) {
|
||||
data.id_arr = this.value.noticeIds.toString();
|
||||
data.page_size = 0;
|
||||
}
|
||||
this.$api.sendRequest({
|
||||
url: '/api/notice/page',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.list = res.data.list;
|
||||
this.bindCrossSlipEvent();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
toLink(item) {
|
||||
if (this.value.sources == 'initial') {
|
||||
this.$util.redirectTo('/pages_tool/notice/detail', {
|
||||
notice_id: item.id
|
||||
});
|
||||
} else if (!item) {
|
||||
this.$util.redirectTo('/pages_tool/notice/list');
|
||||
} else if (item.link && (item.link.wap_url || item.link.appid || item.link.name)) {
|
||||
this.$util.diyRedirectTo(item.link);
|
||||
} else {
|
||||
// 如果不设置跳转链接,则点击弹框展示
|
||||
this.notice = item.content || item.title || '';
|
||||
this.$refs.noticePopup.open();
|
||||
}
|
||||
},
|
||||
closeNoticePopup() {
|
||||
this.$refs.noticePopup.close();
|
||||
},
|
||||
// 绑定横向滚动事件
|
||||
bindCrossSlipEvent() {
|
||||
if (this.value.scrollWay == 'horizontal') {
|
||||
setTimeout(() => {
|
||||
this.$nextTick(() => {
|
||||
uni.createSelectorQuery()
|
||||
.in(this)
|
||||
.select('.marquee-wrap')
|
||||
.boundingClientRect(res => {
|
||||
this.marqueeWrapWidth = res.width;
|
||||
const query = uni.createSelectorQuery().in(this);
|
||||
query
|
||||
.select('.marquee')
|
||||
.boundingClientRect(data => {
|
||||
this.marqueeWidth = data.width + 30; // 30px是间距
|
||||
this.time = Math.ceil(this.marqueeWidth * 10);
|
||||
|
||||
if (this.marqueeWrapWidth > this.marqueeWidth) {
|
||||
this.marqueeStyle = `animation: none;`;
|
||||
this.marqueeAgainStyle = 'display:none;';
|
||||
} else {
|
||||
this.marqueeStyle = `
|
||||
width: ${this.marqueeWidth}px;
|
||||
animation-duration: ${this.time}ms;
|
||||
animation-delay: ${this.delayTime}ms;
|
||||
`;
|
||||
this.marqueeAgainStyle = `
|
||||
width: ${this.marqueeWidth}px;
|
||||
left: ${this.marqueeWidth}px;
|
||||
animation-duration: ${this.time}ms;
|
||||
animation-delay: ${this.delayTime}ms;
|
||||
`;
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.notice {
|
||||
height: 80rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 20rpx 0 20rpx 20rpx;
|
||||
font-size: 70rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.notice-img {
|
||||
width: 44rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.notice-xian {
|
||||
width: 1rpx;
|
||||
height: 26rpx;
|
||||
background-color: #e4e4e4;
|
||||
margin: 0 22rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.main-wrap {
|
||||
display: inline-block;
|
||||
width: calc(100% - 115rpx);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
swiper {
|
||||
height: 50rpx;
|
||||
}
|
||||
|
||||
.beyond-hiding {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-popup {
|
||||
padding: 0 30rpx 40rpx;
|
||||
background-color: #fff;
|
||||
|
||||
.head-wrap {
|
||||
font-size: $font-size-toolbar;
|
||||
line-height: 100rpx;
|
||||
height: 100rpx;
|
||||
display: block;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
border-bottom: 2rpx solid $color-line;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.iconfont {
|
||||
position: absolute;
|
||||
float: right;
|
||||
right: 0;
|
||||
font-size: $font-size-toolbar;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
max-height: 600rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-wrap {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.marquee-wrap {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.marquee {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
animation: marquee 0s 0s linear infinite;
|
||||
|
||||
text {
|
||||
margin-left: 40rpx;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<view data-component-name="diy-picture" class="diy-picture" :style="style">
|
||||
<view class="fui-picture">
|
||||
<view v-for="(item, index) in value.list" :key="index" style="line-height: 0;">
|
||||
<image mode="widthFix" style="width: 100%;height:auto" :src="$util.img(item.imageUrl)"
|
||||
v-if="item.link.wap_url" @tap.stop="handlerClick(item)" show-menu-by-longpress></image>
|
||||
<image mode="widthFix" style="width: 100%;height:auto" :src="$util.img(item.imageUrl)" v-else
|
||||
@tap.stop="handlerClick(item)" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<!-- <view wx:if="{{!childitem.linkurl}}" bindtap="previewImg" data-src="{{childitem.imgurl}}" style="padding:{{diyitem.style.paddingtop==0?0:diyitem.style.paddingtop+'rpx'}} {{diyitem.style.paddingleft==0?0:diyitem.style.paddingleft+'rpx'}}" wx:for="{{diyitem.data}}" wx:for-index="childid" wx:for-item="childitem" wx:key="{{childid}}">
|
||||
<image mode="widthFix" src="{{childitem.imgurl}}" style="{{bannerheight?'height:'+bannerheight+'px':'height:auto'}}"></image>
|
||||
</view> -->
|
||||
</view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-picture',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
computed: {
|
||||
style() {
|
||||
var css = '';
|
||||
css += 'background-color:' + this.value.contentBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return css;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
redirectTo(link) {
|
||||
if (link.wap_url) {
|
||||
if (this.$util.getCurrRoute() == this.$util.MEMBER_PAGE_URL && !this.storeToken) {
|
||||
this.$refs.login.open(link.wap_url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.$util.diyRedirectTo(link);
|
||||
},
|
||||
|
||||
async handlerClick(item) {
|
||||
await this.__$emitEvent({
|
||||
eventName: 'picture-tap', data: item, promiseCallback: (event, handler, awaitedResult) => {
|
||||
if (!awaitedResult) return;
|
||||
|
||||
const link = item.link;
|
||||
if (link?.name || link?.wap_url || link?.appid) {
|
||||
this.redirectTo(item.link);
|
||||
} else {
|
||||
this.previewImg(item.imageUrl);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -1,589 +0,0 @@
|
||||
<template>
|
||||
<x-skeleton data-component-name="diy-pinfan" :type="skeletonType" :loading="loading" :configs="skeletonConfig">
|
||||
<view class="diy-pinfan" :class="[value.template, value.style]" :style="warpCss">
|
||||
<template v-if="value.template == 'row1-of1'">
|
||||
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
|
||||
:class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(index)" show-menu-by-longpress/>
|
||||
</view>
|
||||
<view class="content"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl || value.btnStyle.control">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view class="tag-wrap" v-if="value.groupStyle.control || value.saleStyle.control">
|
||||
<view v-if="value.groupStyle.control"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '', borderColor: value.theme == 'diy' ? value.groupStyle.bgColor : '' }">
|
||||
<text class="iconfont icon-yonghu3"
|
||||
:style="{ backgroundColor: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"></text>
|
||||
<text>{{ $lang('components-diy.diy-pinfan.peopleGroup', { num: item.pintuan_num }) }}</text>
|
||||
</view>
|
||||
<view v-if="value.saleStyle.control"
|
||||
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '', borderColor: value.theme == 'diy' ? value.saleStyle.color : '' }">
|
||||
<text>{{ $lang('components-diy.diy-pinfan.soldCount', { num: item.order_num }) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="price-wrap">
|
||||
<view class="discount-price" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.pintuan_price.split(".")[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
"." + item.pintuan_price.split(".")[1] }}</text>
|
||||
</view>
|
||||
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
|
||||
¥{{ item.price }}
|
||||
</view>
|
||||
<button v-if="value.btnStyle.control" :style="{
|
||||
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
|
||||
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
|
||||
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
|
||||
}">
|
||||
<text class="text">{{ value.btnStyle.text }}</text>
|
||||
<text class="fan" v-if="item.reward_type == 4">{{ $lang('components-diy.diy-pinfan.backPoints', { num: item.reward_type_num }) }}</text>
|
||||
<text class="fan" v-if="item.reward_type == 1 || item.reward_type == 2">{{ $lang('components-diy.diy-pinfan.backCash', { num: item.reward_type_num }) }}</text>
|
||||
<text class="fan" v-if="item.reward_type == 3">{{ $lang('components-diy.diy-pinfan.backCoupon') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-if="value.template == 'horizontal-slide'">
|
||||
<scroll-view v-if="value.slideMode == 'scroll'" class="scroll" :scroll-x="true" :show-scrollbar="false">
|
||||
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
|
||||
:class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(index)" show-menu-by-longpress/>
|
||||
</view>
|
||||
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view v-if="value.groupStyle.control" class="num">
|
||||
<text class="content-tuan-box"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.color : '', backgroundColor: value.theme == 'diy' ? value.groupStyle.bgColor : '' }">
|
||||
{{ $lang('components-diy.diy-pinfan.peopleGroup', { num: item.pintuan_num }) }}
|
||||
</text>
|
||||
<text class="content-tuan-price"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"
|
||||
v-if="item.reward_type == 4">
|
||||
{{ $lang('components-diy.diy-pinfan.backPoints', { num: item.reward_type_num }) }}
|
||||
</text>
|
||||
<text class="content-tuan-price"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"
|
||||
v-if="item.reward_type == 1 || item.reward_type == 2">
|
||||
{{ $lang('components-diy.diy-pinfan.backCash', { num: item.reward_type_num }) }}
|
||||
</text>
|
||||
<text class="content-tuan-price"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"
|
||||
v-if="item.reward_type == 3">{{ $lang('components-diy.diy-pinfan.backCoupon') }}</text>
|
||||
</view>
|
||||
<view class="price-wrap" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.pintuan_price.split('.')[0] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
"." + item.pintuan_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<swiper v-if="value.slideMode == 'slide'" :autoplay="false" class="swiper"
|
||||
:style="{ height: swiperHeight }">
|
||||
<swiper-item v-for="(pageItem, pageIndex) in page" :key="pageIndex"
|
||||
:class="['swiper-item', (list.length && [list[pageIndex].length / 3] >= 1) && 'flex-between']">
|
||||
<view class="item" v-for="(item, dataIndex) in list[pageIndex]" :key="dataIndex"
|
||||
@tap.stop="toDetail(item)" :class="[value.ornament.type]" :style="goodsItemCss">
|
||||
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
|
||||
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
|
||||
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
|
||||
@error="imageError(dataIndex)" show-menu-by-longpress/>
|
||||
</view>
|
||||
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
|
||||
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl">
|
||||
<view v-if="value.goodsNameStyle.control" class="goods-name"
|
||||
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
|
||||
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
|
||||
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
|
||||
</view>
|
||||
<view v-if="value.groupStyle.control" class="num">
|
||||
<text class="content-tuan-box"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.color : '', backgroundColor: value.theme == 'diy' ? value.groupStyle.bgColor : '' }">
|
||||
{{ $lang('components-diy.diy-pinfan.peopleGroup', { num: item.pintuan_num }) }}
|
||||
</text>
|
||||
<text class="content-tuan-price"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"
|
||||
v-if="item.reward_type == 4">
|
||||
{{ $lang('components-diy.diy-pinfan.backPoints', { num: item.reward_type_num }) }}
|
||||
</text>
|
||||
<text class="content-tuan-price"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"
|
||||
v-if="item.reward_type == 1 || item.reward_type == 2">
|
||||
{{ $lang('components-diy.diy-pinfan.backCash', { num: item.reward_type_num }) }}
|
||||
</text>
|
||||
<text class="content-tuan-price"
|
||||
:style="{ color: value.theme == 'diy' ? value.groupStyle.bgColor : '' }"
|
||||
v-if="item.reward_type == 3">
|
||||
{{ $lang('components-diy.diy-pinfan.backCoupon') }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="price-wrap" v-if="value.priceStyle.mainControl">
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
|
||||
<text class="price price-style large"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
item.pintuan_price.split('.')[1] }}</text>
|
||||
<text class="unit price-style small"
|
||||
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
|
||||
"." + item.pintuan_price.split('.')[1] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</template>
|
||||
</view>
|
||||
</x-skeleton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-pinfan',
|
||||
props: {
|
||||
value: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
skeletonType: '',
|
||||
skeletonConfig: {},
|
||||
list: [],
|
||||
page: 1
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.initSkeleton();
|
||||
this.getData();
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
this.getData();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
warpCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.value.componentAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 商品项样式
|
||||
goodsItemCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.value.elementAngle == 'round') {
|
||||
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
|
||||
}
|
||||
if (this.value.ornament.type == 'shadow') {
|
||||
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color + ';';
|
||||
}
|
||||
if (this.value.ornament.type == 'stroke') {
|
||||
obj += 'border:' + '2rpx solid ' + this.value.ornament.color + ';';
|
||||
}
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
if (this.value.template == 'horizontal-slide') {
|
||||
var width = "";
|
||||
if (this.value.slideMode == 'scroll' && this.value.goodsMarginType == 'diy')
|
||||
width = this.rpxUpPx(this.value.goodsMarginNum * 2);
|
||||
else
|
||||
width = [screenWidth - (this.rpxUpPx(20) * 2) - (this.rpxUpPx(200) * 3) - (this.rpxUpPx(this.value
|
||||
.margin.both * 2) * 2)] / 6;
|
||||
obj += 'margin-left:' + width + "px;";
|
||||
obj += 'margin-right:' + width + "px;";
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
swiperHeight() {
|
||||
if (this.value.nameLineMode == 'multiple')
|
||||
return this.value.ornament.type == 'shadow' ? '444rpx' : '432rpx';
|
||||
return this.value.ornament.type == 'shadow' ? '404rpx' : '396rpx';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initSkeleton() {
|
||||
if (this.value.template == 'row1-of1') {
|
||||
|
||||
// 单列 风格
|
||||
this.skeletonType = 'list';
|
||||
this.skeletonConfig = {
|
||||
textRows: 3
|
||||
};
|
||||
|
||||
} else if (this.value.template == 'horizontal-slide') {
|
||||
|
||||
// 横向滑动 风格
|
||||
this.skeletonType = 'waterfall';
|
||||
this.skeletonConfig = {
|
||||
gridRows: 1,
|
||||
gridColumns: 3,
|
||||
headHeight: '200rpx',
|
||||
textRows: 2,
|
||||
textWidth: ['100%', '80%']
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
rpxUpPx(res) {
|
||||
const screenWidth = uni.getSystemInfoSync().windowWidth;
|
||||
var data = screenWidth * parseInt(res) / 750;
|
||||
return Math.floor(data);
|
||||
},
|
||||
getData() {
|
||||
var data = {
|
||||
num: this.value.count
|
||||
};
|
||||
if (this.value.sources == 'diy') {
|
||||
data.num = 0;
|
||||
data.goods_id_arr = this.value.goodsId.toString();
|
||||
}
|
||||
this.$api.sendRequest({
|
||||
url: '/pinfan/api/goods/lists',
|
||||
data: data,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
this.list = res.data;
|
||||
|
||||
// 切屏滚动,每页显示固定数量
|
||||
if (this.value.template == 'horizontal-slide' && this.value.slideMode == 'slide') {
|
||||
let size = 3;
|
||||
let temp = [];
|
||||
this.page = Math.ceil(this.list.length / size);
|
||||
for (var i = 0; i < this.page; i++) {
|
||||
temp[i] = [];
|
||||
for (var j = i * size; j < this.list.length; j++) {
|
||||
if (temp[i].length == size) break;
|
||||
temp[i].push(this.list[j]);
|
||||
}
|
||||
}
|
||||
this.list = temp;
|
||||
}
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
toDetail(e) {
|
||||
this.$util.redirectTo('/pages_promotion/pinfan/detail', {
|
||||
pinfan_id: e.pintuan_id
|
||||
});
|
||||
},
|
||||
imageError(index) {
|
||||
this.list[index].goods_image = this.$util.getDefaultImage().goods;
|
||||
this.$forceUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
::v-deep .uni-scroll-view ::-webkit-scrollbar {
|
||||
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::v-deep ::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
scroll-view ::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.diy-pinfan {
|
||||
&.row1-of1 {
|
||||
.item {
|
||||
display: flex;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 16rpx;
|
||||
|
||||
&.shadow {
|
||||
margin: 8rpx 8rpx 20rpx 8rpx;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
|
||||
image {
|
||||
width: 200rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 6rpx 0;
|
||||
|
||||
.goods-name {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tag-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4rpx;
|
||||
|
||||
>view {
|
||||
height: 30rpx;
|
||||
line-height: 30rpx;
|
||||
border: 2rpx solid $base-color;
|
||||
border-radius: 6rpx;
|
||||
margin-right: 10rpx;
|
||||
font-size: $font-size-activity-tag;
|
||||
color: $base-color;
|
||||
|
||||
.iconfont {
|
||||
display: inline-block;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
font-size: $font-size-activity-tag;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
margin-right: -6rpx;
|
||||
background: $base-color;
|
||||
}
|
||||
|
||||
text:last-child {
|
||||
padding: 0 10rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
>view:last-of-type {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.discount-price {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
color: $base-color;
|
||||
}
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: $font-size-tag;
|
||||
color: $color-tip;
|
||||
text-decoration: line-through;
|
||||
margin: 0 10rpx 4rpx 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
bottom: 10rpx;
|
||||
height: 90rpx;
|
||||
line-height: 40rpx;
|
||||
padding: 0 16rpx;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
background-color: $base-color;
|
||||
color: #ffffff;
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: $font-size-tag;
|
||||
}
|
||||
|
||||
.fan {
|
||||
font-size: $font-size-activity-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal-slide {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
.scroll {
|
||||
width: calc(100% - 40rpx);
|
||||
padding: 20rpx;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.item.shadow {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-block;
|
||||
width: 200rpx;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(3n+3) {
|
||||
width: 198rpx;
|
||||
}
|
||||
|
||||
&.shadow {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.img-wrap {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
|
||||
>image {
|
||||
width: 200rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&.multi-content {
|
||||
height: 188rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
line-height: 1.3;
|
||||
|
||||
&.multi-hidden {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
}
|
||||
|
||||
.num {
|
||||
margin-top: auto;
|
||||
font-size: $font-size-activity-tag;
|
||||
|
||||
.content-tuan-box {
|
||||
border-radius: 4rpx;
|
||||
color: #ffffff;
|
||||
background-color: $base-color;
|
||||
padding: 6rpx;
|
||||
}
|
||||
|
||||
.content-tuan-price {
|
||||
border-radius: 4rpx;
|
||||
padding: 4rpx 6rpx;
|
||||
border: 2rpx solid $base-color;
|
||||
color: $base-color;
|
||||
margin-left: -2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.price-wrap {
|
||||
white-space: nowrap;
|
||||
margin-top: 10rpx;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
|
||||
.unit {
|
||||
font-size: $font-size-tag;
|
||||
height: 32rpx;
|
||||
color: $base-color;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: $font-size-toolbar;
|
||||
color: $base-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swiper {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.swiper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 200rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user