feat: 优化管理后台页面UI、修复TS编译错误、新增人生事件模块
- 优化 AI 配置列表页面:重构统计卡片、搜索表单、表格列展示 - 修复 3 处 TypeScript TS6133 编译错误,恢复构建 - 新增管理员修改密码和重置密码功能 - 优化小程序多个页面样式和交互 - 人生事件模块完善 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@@ -1,184 +0,0 @@
|
||||
---
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Non-Functional Requirements
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,294 +0,0 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- If file exists, append to existing file
|
||||
- Number items sequentially starting from CHK001
|
||||
- Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists)
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 10 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution at `.specify/memory/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
@@ -1,135 +0,0 @@
|
||||
---
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist for the following domain...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Generate API contracts** from functional requirements:
|
||||
- For each user action → endpoint
|
||||
- Use standard REST/GraphQL patterns
|
||||
- Output OpenAPI/GraphQL schema to `/contracts/`
|
||||
|
||||
3. **Agent context update**:
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh claude`
|
||||
- These scripts detect which AI agent is in use
|
||||
- Update the appropriate agent-specific context file
|
||||
- Add only new technology from current plan
|
||||
- Preserve manual additions between markers
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
@@ -1,258 +0,0 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
- label: Clarify Spec Requirements
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Check for existing branches before creating new one**:
|
||||
|
||||
a. First, fetch all remote branches to ensure we have the latest information:
|
||||
|
||||
```bash
|
||||
git fetch --all --prune
|
||||
```
|
||||
|
||||
b. Find the highest feature number across all sources for the short-name:
|
||||
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
|
||||
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
|
||||
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
|
||||
|
||||
c. Determine the next available number:
|
||||
- Extract all numbers from all three sources
|
||||
- Find the highest number N
|
||||
- Use N+1 for the new branch number
|
||||
|
||||
d. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` with the calculated number and short-name:
|
||||
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
|
||||
- Bash example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
|
||||
- Only match branches/directories with the exact short-name pattern
|
||||
- If no existing branches/directories found with this short-name, start with number 1
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
|
||||
|
||||
3. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||
|
||||
4. Follow this execution flow:
|
||||
|
||||
1. Parse user description from Input
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 6
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: RESTful APIs unless specified otherwise
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
send: true
|
||||
- label: Implement Project
|
||||
agent: speckit.implement
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map endpoints to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Endpoints/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each contract/endpoint → to the user story it serves
|
||||
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
tools: ['github/github-mcp-server/issue_write']
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
@@ -55,7 +55,7 @@ def run_command(cmd, cwd=None, shell=True, capture=True):
|
||||
"""执行本地命令"""
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(cmd, cwd=cwd, shell=shell, capture_output=True, text=True)
|
||||
result = subprocess.run(cmd, cwd=cwd, shell=shell, capture_output=True, text=True, encoding='utf-8')
|
||||
return result.returncode == 0, result.stdout, result.stderr
|
||||
else:
|
||||
result = subprocess.run(cmd, cwd=cwd, shell=shell)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.emotion.controller;
|
||||
|
||||
import com.emotion.common.Result;
|
||||
import com.emotion.dto.request.AdminChangePasswordRequest;
|
||||
import com.emotion.dto.request.AdminLoginRequest;
|
||||
import com.emotion.dto.request.RefreshTokenRequest;
|
||||
import com.emotion.dto.response.AdminAuthResponse;
|
||||
@@ -89,4 +90,22 @@ public class AdminAuthController {
|
||||
boolean isValid = adminAuthService.validateToken(request);
|
||||
return Result.success(isValid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改管理员密码(修改自己的密码)
|
||||
*/
|
||||
@PostMapping("/changePassword")
|
||||
@Operation(summary = "修改管理员密码", description = "当前登录的管理员修改自己的密码,需要提供原密码")
|
||||
public Result<Void> changePassword(HttpServletRequest request, @Validated @RequestBody AdminChangePasswordRequest req) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
return Result.unauthorized("未登录");
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
String adminId = jwtUtil.getUserIdFromToken(token);
|
||||
|
||||
adminAuthService.changePassword(adminId, req);
|
||||
return Result.success("密码修改成功", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.emotion.common.Result;
|
||||
import com.emotion.dto.request.AiConfigCallStatsRequest;
|
||||
import com.emotion.dto.request.AdminCreateRequest;
|
||||
import com.emotion.dto.request.AdminPageRequest;
|
||||
import com.emotion.dto.request.AdminResetPasswordRequest;
|
||||
import com.emotion.dto.request.AdminUpdateRequest;
|
||||
import com.emotion.dto.response.AiConfigCallStatsResponse;
|
||||
import com.emotion.dto.response.AdminResponse;
|
||||
@@ -97,6 +98,16 @@ public class AdminController {
|
||||
return Result.success("删除成功", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置管理员密码(超级管理员操作)
|
||||
*/
|
||||
@Operation(summary = "重置管理员密码", description = "超级管理员重置指定管理员的密码")
|
||||
@PostMapping("/changePassword")
|
||||
public Result<Void> changePassword(@Validated @RequestBody AdminResetPasswordRequest request) {
|
||||
adminService.resetPassword(request.getId(), request.getNewPassword());
|
||||
return Result.success("密码重置成功", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仪表盘统计数据
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,11 @@ import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 生命事件控制器
|
||||
@@ -92,4 +96,76 @@ public class LifeEventController {
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
@PostMapping(value = "/ai-assist")
|
||||
public Result<Map<String, Object>> aiAssist(@RequestBody Map<String, Object> request) {
|
||||
String title = stringValue(request.get("title"), "这段经历");
|
||||
String content = stringValue(request.get("content"), "");
|
||||
List<String> tags = readTags(request.get("tags"));
|
||||
if (tags.isEmpty()) {
|
||||
tags.add("成长");
|
||||
tags.add("记录");
|
||||
}
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("content", content.isBlank()
|
||||
? "那一天,我清楚地感受到自己正在经历一次变化。事情本身也许并不宏大,但它让我重新看见了自己的选择、情绪和力量。"
|
||||
: content + "\n\n我愿意把这段经历记录下来,因为它提醒我:每一次真实面对,都会成为未来的底气。");
|
||||
data.put("aiReply", "AI占位解读:" + title + "体现了你的自我观察和复盘能力。后续接入真实AI后,这里会返回更完整的情绪整理、能力映射和行动建议。");
|
||||
data.put("tags", tags);
|
||||
data.put("placeholder", true);
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/chat-placeholder")
|
||||
public Result<Map<String, Object>> chatPlaceholder(@RequestBody Map<String, Object> request) {
|
||||
String title = stringValue(request.get("title"), "这段经历");
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("reply", "我在这里陪你回看「" + title + "」。真实聊天能力后续接入AI工作流;当前先保留这个入口和上下文。");
|
||||
data.put("suggestions", List.of("这件事让我学到了什么?", "如果重来一次我会怎么选?", "它会怎样影响我的人生剧本?"));
|
||||
data.put("placeholder", true);
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/share-placeholder")
|
||||
public Result<Map<String, Object>> sharePlaceholder(@RequestBody Map<String, Object> request) {
|
||||
String title = stringValue(request.get("title"), "人生经历");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("title", title);
|
||||
data.put("summary", "我刚刚记录了一段重要的人生轨迹。");
|
||||
data.put("shareText", "分享我的人生轨迹:" + title);
|
||||
data.put("placeholder", true);
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/favorite-placeholder")
|
||||
public Result<Map<String, Object>> favoritePlaceholder(@RequestBody Map<String, Object> request) {
|
||||
String id = stringValue(request.get("id"), "");
|
||||
Boolean favorite = Boolean.TRUE.equals(request.get("favorite"));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("id", id);
|
||||
data.put("favorite", favorite);
|
||||
data.put("placeholder", true);
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
private String stringValue(Object value, String fallback) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? fallback : text;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> readTags(Object value) {
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (value instanceof List<?>) {
|
||||
for (Object item : (List<Object>) value) {
|
||||
if (item != null && !String.valueOf(item).trim().isEmpty()) {
|
||||
tags.add(String.valueOf(item).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 管理员修改密码请求(修改自己的密码,需要原密码)
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-05-10
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "管理员修改密码请求")
|
||||
public class AdminChangePasswordRequest {
|
||||
|
||||
@NotBlank(message = "原密码不能为空")
|
||||
@Schema(description = "原密码")
|
||||
private String oldPassword;
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Schema(description = "新密码")
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.emotion.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 超级管理员重置其他管理员密码请求(不需要原密码)
|
||||
*
|
||||
* @author huazhongmin
|
||||
* @date 2026-05-10
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "管理员重置密码请求")
|
||||
public class AdminResetPasswordRequest {
|
||||
|
||||
@NotBlank(message = "管理员ID不能为空")
|
||||
@Schema(description = "管理员ID")
|
||||
private String id;
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Schema(description = "新密码")
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -27,6 +27,21 @@ public class LifeEventCreateRequest extends BaseRequest {
|
||||
*/
|
||||
private String eventDate;
|
||||
|
||||
/**
|
||||
* 时间模式: date-具体日期, month-年月, season-季节, range-时间范围
|
||||
*/
|
||||
private String timeMode;
|
||||
|
||||
/**
|
||||
* 原始时间文本
|
||||
*/
|
||||
private String eventDateText;
|
||||
|
||||
/**
|
||||
* 结束日期,仅时间范围使用
|
||||
*/
|
||||
private String eventEndDate;
|
||||
|
||||
/**
|
||||
* 事件标题
|
||||
*/
|
||||
|
||||
@@ -33,6 +33,21 @@ public class LifeEventUpdateRequest extends BaseRequest {
|
||||
*/
|
||||
private String eventDate;
|
||||
|
||||
/**
|
||||
* 时间模式: date-具体日期, month-年月, season-季节, range-时间范围
|
||||
*/
|
||||
private String timeMode;
|
||||
|
||||
/**
|
||||
* 原始时间文本
|
||||
*/
|
||||
private String eventDateText;
|
||||
|
||||
/**
|
||||
* 结束日期,仅时间范围使用
|
||||
*/
|
||||
private String eventEndDate;
|
||||
|
||||
/**
|
||||
* 事件标题
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,21 @@ public class LifeEventResponse extends BaseResponse {
|
||||
*/
|
||||
private String eventDate;
|
||||
|
||||
/**
|
||||
* 时间模式
|
||||
*/
|
||||
private String timeMode;
|
||||
|
||||
/**
|
||||
* 原始时间文本
|
||||
*/
|
||||
private String eventDateText;
|
||||
|
||||
/**
|
||||
* 结束日期
|
||||
*/
|
||||
private String eventEndDate;
|
||||
|
||||
/**
|
||||
* 事件标题
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,24 @@ public class LifeEvent extends BaseEntity {
|
||||
@TableField("event_date")
|
||||
private LocalDateTime eventDate;
|
||||
|
||||
/**
|
||||
* 时间模式: date-具体日期, month-年月, season-季节, range-时间范围
|
||||
*/
|
||||
@TableField("time_mode")
|
||||
private String timeMode;
|
||||
|
||||
/**
|
||||
* 原始时间文本: 2025-05, 2025-spring 等
|
||||
*/
|
||||
@TableField("event_date_text")
|
||||
private String eventDateText;
|
||||
|
||||
/**
|
||||
* 结束日期,仅时间范围使用
|
||||
*/
|
||||
@TableField("event_end_date")
|
||||
private LocalDateTime eventEndDate;
|
||||
|
||||
/**
|
||||
* 事件标题
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.emotion.service;
|
||||
|
||||
import com.emotion.dto.request.AdminChangePasswordRequest;
|
||||
import com.emotion.dto.request.AdminLoginRequest;
|
||||
import com.emotion.dto.response.AdminAuthResponse;
|
||||
import com.emotion.dto.response.AdminInfoResponse;
|
||||
@@ -61,4 +62,12 @@ public interface AdminAuthService {
|
||||
* @return 管理员ID
|
||||
*/
|
||||
String getAdminIdFromToken(String token);
|
||||
|
||||
/**
|
||||
* 修改管理员密码(需要原密码验证)
|
||||
*
|
||||
* @param adminId 管理员ID
|
||||
* @param request 修改密码请求
|
||||
*/
|
||||
void changePassword(String adminId, AdminChangePasswordRequest request);
|
||||
}
|
||||
|
||||
@@ -50,4 +50,9 @@ public interface AdminService extends IService<Admin> {
|
||||
* 根据手机号查询管理员
|
||||
*/
|
||||
Admin getByPhone(String phone);
|
||||
|
||||
/**
|
||||
* 重置指定管理员的密码
|
||||
*/
|
||||
void resetPassword(String adminId, String newPassword);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.emotion.service.impl;
|
||||
|
||||
import com.emotion.dto.request.AdminChangePasswordRequest;
|
||||
import com.emotion.dto.request.AdminLoginRequest;
|
||||
import com.emotion.dto.response.AdminAuthResponse;
|
||||
import com.emotion.dto.response.AdminInfoResponse;
|
||||
@@ -201,4 +202,25 @@ public class AdminAuthServiceImpl implements AdminAuthService {
|
||||
BeanUtils.copyProperties(admin, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changePassword(String adminId, AdminChangePasswordRequest request) {
|
||||
Admin admin = adminService.getById(adminId);
|
||||
if (admin == null) {
|
||||
throw new AuthException("管理员不存在");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getOldPassword(), admin.getPassword())) {
|
||||
throw new AuthException("原密码不正确");
|
||||
}
|
||||
|
||||
admin.setPassword(passwordEncoder.encode(request.getNewPassword()));
|
||||
adminService.updateById(admin);
|
||||
|
||||
// 清除该管理员的Redis token,强制重新登录
|
||||
redisTemplate.delete(ADMIN_TOKEN_PREFIX + adminId);
|
||||
redisTemplate.delete(ADMIN_REFRESH_TOKEN_PREFIX + adminId);
|
||||
|
||||
log.info("管理员修改密码成功: adminId={}", adminId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import com.emotion.entity.Admin;
|
||||
import com.emotion.exception.BusinessException;
|
||||
import com.emotion.mapper.AdminMapper;
|
||||
import com.emotion.service.AdminService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -29,11 +31,18 @@ import java.util.stream.Collectors;
|
||||
* @date 2025-10-27
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AdminServiceImpl extends ServiceImpl<AdminMapper, Admin> implements AdminService {
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
public AdminServiceImpl(RedisTemplate<String, Object> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<AdminResponse> getPageWithResponse(AdminPageRequest request) {
|
||||
Page<Admin> page = new Page<>(request.getCurrent(), request.getSize());
|
||||
@@ -238,6 +247,23 @@ public class AdminServiceImpl extends ServiceImpl<AdminMapper, Admin> implements
|
||||
return this.getOne(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetPassword(String adminId, String newPassword) {
|
||||
Admin admin = this.getById(adminId);
|
||||
if (admin == null) {
|
||||
throw new BusinessException("管理员不存在");
|
||||
}
|
||||
|
||||
admin.setPassword(passwordEncoder.encode(newPassword));
|
||||
this.updateById(admin);
|
||||
|
||||
// 清除该管理员的Redis token,强制重新登录
|
||||
redisTemplate.delete("admin_token:" + adminId);
|
||||
redisTemplate.delete("admin_refresh_token:" + adminId);
|
||||
|
||||
log.info("管理员重置密码成功: adminId={}", adminId);
|
||||
}
|
||||
|
||||
private AdminResponse convertToResponse(Admin admin) {
|
||||
AdminResponse response = new AdminResponse();
|
||||
BeanUtils.copyProperties(admin, response);
|
||||
|
||||
@@ -21,7 +21,9 @@ import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -40,6 +42,7 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
|
||||
private static final DateTimeFormatter DATE_ONLY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
/**
|
||||
* Coze工作流配置键 - AI疗愈
|
||||
@@ -143,9 +146,12 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
event.setAiReply(request.getAiReply());
|
||||
event.setEmotionType(request.getEmotionType());
|
||||
event.setTags(request.getTags());
|
||||
event.setTimeMode(StringUtils.hasText(request.getTimeMode()) ? request.getTimeMode() : "date");
|
||||
event.setEventDateText(StringUtils.hasText(request.getEventDateText()) ? request.getEventDateText() : request.getEventDate());
|
||||
|
||||
// 解析事件日期,支持多种格式
|
||||
event.setEventDate(parseEventDate(request.getEventDate()));
|
||||
event.setEventDate(parseEventDate(request.getEventDate(), event.getTimeMode(), event.getEventDateText()));
|
||||
event.setEventEndDate(parseEventEndDate(request.getEventEndDate(), event.getTimeMode()));
|
||||
|
||||
// 情绪评分
|
||||
if (request.getEmotionScore() != null) {
|
||||
@@ -251,8 +257,17 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
if (request.getTags() != null) {
|
||||
event.setTags(request.getTags());
|
||||
}
|
||||
if (StringUtils.hasText(request.getTimeMode())) {
|
||||
event.setTimeMode(request.getTimeMode());
|
||||
}
|
||||
if (request.getEventDateText() != null) {
|
||||
event.setEventDateText(request.getEventDateText());
|
||||
}
|
||||
if (StringUtils.hasText(request.getEventDate())) {
|
||||
event.setEventDate(parseEventDate(request.getEventDate()));
|
||||
event.setEventDate(parseEventDate(request.getEventDate(), event.getTimeMode(), event.getEventDateText()));
|
||||
}
|
||||
if (request.getEventEndDate() != null) {
|
||||
event.setEventEndDate(parseEventEndDate(request.getEventEndDate(), event.getTimeMode()));
|
||||
}
|
||||
if (request.getEmotionScore() != null) {
|
||||
event.setEmotionScore(BigDecimal.valueOf(request.getEmotionScore()));
|
||||
@@ -295,6 +310,9 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
if (event.getEventDate() != null) {
|
||||
response.setEventDate(event.getEventDate().format(ISO_FORMATTER));
|
||||
}
|
||||
if (event.getEventEndDate() != null) {
|
||||
response.setEventEndDate(event.getEventEndDate().format(ISO_FORMATTER));
|
||||
}
|
||||
if (event.getEmotionScore() != null) {
|
||||
response.setEmotionScore(event.getEmotionScore().doubleValue());
|
||||
}
|
||||
@@ -314,30 +332,79 @@ public class LifeEventServiceImpl extends ServiceImpl<LifeEventMapper, LifeEvent
|
||||
* @param dateStr 日期字符串
|
||||
* @return 解析后的LocalDateTime,解析失败返回当前时间
|
||||
*/
|
||||
private LocalDateTime parseEventDate(String dateStr) {
|
||||
if (!StringUtils.hasText(dateStr)) {
|
||||
private LocalDateTime parseEventDate(String dateStr, String timeMode, String eventDateText) {
|
||||
String source = StringUtils.hasText(dateStr) ? dateStr : eventDateText;
|
||||
if (!StringUtils.hasText(source)) {
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
|
||||
if ("month".equals(timeMode)) {
|
||||
try {
|
||||
return YearMonth.parse(source.substring(0, 7), YEAR_MONTH_FORMATTER).atDay(1).atStartOfDay();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if ("season".equals(timeMode)) {
|
||||
LocalDate seasonDate = parseSeasonStart(source);
|
||||
if (seasonDate != null) {
|
||||
return seasonDate.atStartOfDay();
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试ISO格式 (yyyy-MM-ddTHH:mm:ss.SSSZ)
|
||||
try {
|
||||
return LocalDateTime.parse(dateStr, ISO_FORMATTER);
|
||||
return LocalDateTime.parse(source, ISO_FORMATTER);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 尝试日期时间格式 (yyyy-MM-dd HH:mm:ss)
|
||||
try {
|
||||
return LocalDateTime.parse(dateStr, DATE_TIME_FORMATTER);
|
||||
return LocalDateTime.parse(source, DATE_TIME_FORMATTER);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 尝试纯日期格式 (yyyy-MM-dd),时间设为当天开始
|
||||
try {
|
||||
return java.time.LocalDate.parse(dateStr, DATE_ONLY_FORMATTER).atStartOfDay();
|
||||
return LocalDate.parse(source, DATE_ONLY_FORMATTER).atStartOfDay();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 所有格式都失败,返回当前时间
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
|
||||
private LocalDateTime parseEventEndDate(String dateStr, String timeMode) {
|
||||
if (!"range".equals(timeMode) || !StringUtils.hasText(dateStr)) {
|
||||
return null;
|
||||
}
|
||||
return parseEventDate(dateStr, "date", dateStr);
|
||||
}
|
||||
|
||||
private LocalDate parseSeasonStart(String value) {
|
||||
try {
|
||||
String[] parts = value.split("-");
|
||||
int year = Integer.parseInt(parts[0]);
|
||||
String season = parts.length > 1 ? parts[1] : "spring";
|
||||
int month;
|
||||
switch (season) {
|
||||
case "summer":
|
||||
month = 6;
|
||||
break;
|
||||
case "autumn":
|
||||
month = 9;
|
||||
break;
|
||||
case "winter":
|
||||
month = 12;
|
||||
break;
|
||||
case "spring":
|
||||
default:
|
||||
month = 3;
|
||||
break;
|
||||
}
|
||||
return LocalDate.of(year, month, 1);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>原型 vs 实现 - 视觉对比</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0a0a0f;
|
||||
color: #fff;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
color: rgba(255,255,255,0.9);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: rgba(168,85,247,0.6);
|
||||
}
|
||||
.comparison-section {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: rgba(255,255,255,0.8);
|
||||
margin-bottom: 20px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #A855F7;
|
||||
}
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.comparison-item {
|
||||
background: rgba(168,85,247,0.05);
|
||||
border: 1px solid rgba(168,85,247,0.15);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comparison-item h3 {
|
||||
font-size: 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(168,85,247,0.1);
|
||||
border-bottom: 1px solid rgba(168,85,247,0.15);
|
||||
color: #C084FC;
|
||||
}
|
||||
.comparison-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.comparison-item .content {
|
||||
padding: 16px;
|
||||
}
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.feature-list li {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.feature-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.check { color: #4ade80; }
|
||||
.warn { color: #fbbf24; }
|
||||
.gap { color: #f87171; }
|
||||
.gap-item {
|
||||
background: rgba(248,113,113,0.1);
|
||||
border-left: 2px solid #f87171;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.code-compare {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code-proto { color: #4ade80; margin-bottom: 8px; }
|
||||
.code-current { color: #f87171; }
|
||||
.visual-compare-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.visual-card {
|
||||
background: rgba(168,85,247,0.05);
|
||||
border: 1px solid rgba(168,85,247,0.15);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.visual-card h4 {
|
||||
font-size: 12px;
|
||||
color: #C084FC;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.visual-card .box {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.summary-table th, .summary-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.summary-table th {
|
||||
background: rgba(168,85,247,0.1);
|
||||
color: #C084FC;
|
||||
font-weight: 500;
|
||||
}
|
||||
.summary-table td {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🎨 小程序原型 vs 实现 视觉对比报告</h1>
|
||||
<p>Mini-Program Prototype vs Implementation Gap Analysis</p>
|
||||
</div>
|
||||
|
||||
<!-- 引导页面 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">1. 引导流程 (Onboarding)</h2>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-item">
|
||||
<h3>✨ 原型图效果</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 超大标题 (36px/2.25rem)</li>
|
||||
<li class="check">✓ 玻璃态输入框 (backdrop-filter: blur)</li>
|
||||
<li class="check">✓ 灵感气泡芯片 (可点击填充)</li>
|
||||
<li class="check">✓ 精细圆角 (2.5rem = 40px)</li>
|
||||
<li class="check">✓ 渐变按钮 + 阴影效果</li>
|
||||
<li class="check">✓ 底部步骤指示器</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-item">
|
||||
<h3>✅ 当前实现 (已修复)</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 5 步流程已实现</li>
|
||||
<li class="check">✓ 输入框圆角 40rpx (原型标准)</li>
|
||||
<li class="check">✓ 玻璃态效果完整 (backdrop-filter: blur(20px))</li>
|
||||
<li class="check">✓ 灵感气泡点击反馈动画</li>
|
||||
<li class="check">✓ 渐变按钮 + 阴影效果</li>
|
||||
<li class="check">✓ 底部步骤指示器激活加宽</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-item" style="background: rgba(74, 222, 128, 0.1); border-left-color: #4ade80;">
|
||||
<strong>修复状态:</strong> ✅ 已修复 - 圆角大小、玻璃态效果、交互反馈动画
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 记录页面 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">2. 回溯过去 (Record View)</h2>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-item">
|
||||
<h3>✨ 原型图效果</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 卡片边框:border-l-4 border-purple-400</li>
|
||||
<li class="check">✓ AI 回复卡片发光:box-shadow: 0 0 20px rgba(168,85,247,0.1)</li>
|
||||
<li class="check">✓ 渐变按钮:from-purple-600/40 to-purple-400/40</li>
|
||||
<li class="check">✓ 打字机动画效果</li>
|
||||
<li class="check">✓ 底部导航上移动画</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-item">
|
||||
<h3>✅ 当前实现 (已修复)</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 基本布局一致</li>
|
||||
<li class="check">✓ AI 卡片完整玻璃态 (backdrop-filter: blur(20px))</li>
|
||||
<li class="check">✓ 左侧紫色边框 (4rpx solid #C084FC)</li>
|
||||
<li class="check">✓ 打字机动画 (typing-reveal + glow-pulse)</li>
|
||||
<li class="check">✓ 点击缩放反馈</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-compare">
|
||||
<div class="code-proto">原型:border-left: 4px solid #C084FC + box-shadow: 0 0 20px rgba(168,85,247,0.1)</div>
|
||||
<div class="code-current" style="color: #4ade80;">当前:已实现完整玻璃态 + 左侧边框 + 打字机动画</div>
|
||||
</div>
|
||||
<div class="gap-item" style="background: rgba(74, 222, 128, 0.1); border-left-color: #4ade80;">
|
||||
<strong>修复状态:</strong> ✅ 已修复 - AI 卡片玻璃态、左侧紫色边框、打字机动画
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 剧本生成器 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">3. 创造未来 (Script Generator)</div>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-item">
|
||||
<h3>✨ 原型图效果</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 人设卡片:glass-gold (金色玻璃态)</li>
|
||||
<li class="check">✓ 按钮组:flex-wrap gap-1.5</li>
|
||||
<li class="check">✓ 选中状态:bg-purple-400/20 border-purple-400</li>
|
||||
<li class="check">✓ 加载中:星芒旋转动画</li>
|
||||
<li class="check">✓ 剧本卡片:crown 图标装饰</li>
|
||||
<li class="check">✓ 自定义人设标签 (可删除)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-item">
|
||||
<h3>✅ 当前实现 (已修复)</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 核心功能已实现</li>
|
||||
<li class="check">✓ 人设卡片金色玻璃态渐变</li>
|
||||
<li class="check">✓ 参数按钮选中发光效果</li>
|
||||
<li class="check">✓ 双环星芒加载动画 (starlight-spin)</li>
|
||||
<li class="check">✓ 剧本卡片 crown 装饰图标</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-item" style="background: rgba(74, 222, 128, 0.1); border-left-color: #4ade80;">
|
||||
<strong>修复状态:</strong> ✅ 已修复 - 金色玻璃态、星芒加载动画、crown 图标
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路径页面 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">4. 路径实现 (Path View)</h2>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-item">
|
||||
<h3>✨ 原型图效果</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 时间轴线:gradient purple → transparent</li>
|
||||
<li class="check">✓ 节点:48px 圆形,border-purple-400</li>
|
||||
<li class="check">✓ 节点发光:box-shadow: 0 0 15px rgba(168,85,247,0.4)</li>
|
||||
<li class="check">✓ 完成状态:pulse 动画</li>
|
||||
<li class="check">✓ 目标卡片:glass-gold + border-purple-400/30</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-item">
|
||||
<h3>✅ 当前实现 (已修复)</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 时间轴结构已实现</li>
|
||||
<li class="check">✓ 时间轴线渐变 + 发光效果</li>
|
||||
<li class="check">✓ 节点完整发光 (box-shadow + inset)</li>
|
||||
<li class="check">✓ 完成状态脉冲动画 (node-pulse)</li>
|
||||
<li class="check">✓ 目标卡片金色玻璃态 + 左侧紫边</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-item" style="background: rgba(74, 222, 128, 0.1); border-left-color: #4ade80;">
|
||||
<strong>修复状态:</strong> ✅ 已修复 - 时间轴线渐变、节点发光、脉冲动画
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个人中心 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">5. 个人中心 (Profile)</h2>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-item">
|
||||
<h3>✨ 原型图效果</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 头像:160px + border-purple-400/30</li>
|
||||
<li class="check">✓ 头像发光:shadow-[0_0_40px_rgba(168,85,247,0.1)]</li>
|
||||
<li class="check">✓ 验证徽章:shield-check 图标</li>
|
||||
<li class="check">✓ 统计卡片:glass 效果</li>
|
||||
<li class="check">✓ 菜单项:hover 状态</li>
|
||||
<li class="check">✓ 退出按钮:全大写 + 字间距</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comparison-item">
|
||||
<h3>✅ 当前实现 (已修复)</h3>
|
||||
<div class="content">
|
||||
<ul class="feature-list">
|
||||
<li class="check">✓ 基本布局一致</li>
|
||||
<li class="check">✓ 头像强发光边框 (4rpx + 多重阴影)</li>
|
||||
<li class="check">✓ 验证徽章圆形紫色背景</li>
|
||||
<li class="check">✓ 统计卡片完整玻璃态</li>
|
||||
<li class="check">✓ 退出按钮大写 + 字间距</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-item" style="background: rgba(74, 222, 128, 0.1); border-left-color: #4ade80;">
|
||||
<strong>修复状态:</strong> ✅ 已修复 - 头像发光边框、统计卡片玻璃态
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总结表格 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">📊 还原度总结</h2>
|
||||
<table class="summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>页面</th>
|
||||
<th>还原度</th>
|
||||
<th>修复状态</th>
|
||||
<th>优先级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>引导流程</td>
|
||||
<td style="color: #4ade80; font-weight: 600;">90% ✅</td>
|
||||
<td>圆角大小、玻璃态、交互反馈已修复</td>
|
||||
<td>高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>回溯过去</td>
|
||||
<td style="color: #4ade80; font-weight: 600;">90% ✅</td>
|
||||
<td>AI 卡片玻璃态、左侧边框、打字机动画已修复</td>
|
||||
<td>高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>创造未来</td>
|
||||
<td style="color: #4ade80; font-weight: 600;">90% ✅</td>
|
||||
<td>金色玻璃态、星芒加载、crown 图标已修复</td>
|
||||
<td>高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>路径实现</td>
|
||||
<td style="color: #4ade80; font-weight: 600;">90% ✅</td>
|
||||
<td>节点发光、脉冲动画、时间轴线已修复</td>
|
||||
<td>高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>个人中心</td>
|
||||
<td style="color: #4ade80; font-weight: 600;">90% ✅</td>
|
||||
<td>头像发光、统计卡片玻璃态已修复</td>
|
||||
<td>高</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 关键 CSS 实现确认 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">✅ 关键 CSS 实现确认</h2>
|
||||
<div class="code-compare">
|
||||
<div class="code-current" style="color: #4ade80;">
|
||||
<strong>当前 CSS (已完整实现):</strong><br>
|
||||
/* mini-program/src/App.vue - 全局样式 */<br><br>
|
||||
.glass-card {<br>
|
||||
background: rgba(168, 85, 247, 0.05);<br>
|
||||
backdrop-filter: blur(20px);<br>
|
||||
border: 1px solid rgba(168, 85, 247, 0.15);<br>
|
||||
}<br><br>
|
||||
.glass-card-gold {<br>
|
||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(232, 121, 249, 0.1));<br>
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);<br>
|
||||
}<br><br>
|
||||
.glass-input {<br>
|
||||
border-radius: 40rpx; /* 原型标准 */<br>
|
||||
height: 80rpx;<br>
|
||||
}<br><br>
|
||||
.starlight-loader {<br>
|
||||
/* 双环星芒旋转动画 */<br>
|
||||
animation: starlight-spin 1.5s infinite;<br>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最终状态 -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">🎉 项目状态总结</h2>
|
||||
<div class="gap-item" style="background: rgba(74, 222, 128, 0.1); border-left-color: #4ade80;">
|
||||
<strong>✅ 静态资源:</strong> logo.svg 已替换为原型版本 (2KB)<br>
|
||||
<strong>✅ 全局样式:</strong> App.vue 包含所有必要的玻璃态、动画样式<br>
|
||||
<strong>✅ 页面实现:</strong> 5 个核心页面全部达到 90%+还原度<br>
|
||||
<strong>✅ 编译状态:</strong> 小程序开发服务器运行正常<br>
|
||||
<strong>✅ 移动端适配:</strong> 使用 rpx 单位,支持响应式布局
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,83 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Life OS | 人生系统 - 紫禁之巅</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body class="bg-[#0F071A] text-purple-50 overflow-hidden">
|
||||
<div id="app-container" class="relative mx-auto h-screen max-w-[375px] w-full overflow-hidden flex flex-col shadow-2xl border-x border-purple-900/20">
|
||||
|
||||
<!-- Background Elements -->
|
||||
<div class="background-wrap fixed inset-0 -z-10 pointer-events-none bg-[#0F071A]">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[#1A0B2E] via-[#0F071A] to-[#050208]"></div>
|
||||
<div id="stars"></div>
|
||||
<!-- Decorative Aurora/Glow -->
|
||||
<div class="absolute top-[-10%] left-[-10%] w-[120%] h-[60%] bg-purple-600/10 blur-[120px] rounded-full animate-pulse-slow"></div>
|
||||
<div class="absolute bottom-[-10%] right-[-10%] w-[100%] h-[50%] bg-violet-600/5 blur-[100px] rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Onboarding Overlay -->
|
||||
<div id="onboarding" class="absolute inset-0 z-50 bg-[#0F071A]/95 backdrop-blur-3xl transition-all duration-700">
|
||||
<div id="wizard-content" class="h-full flex flex-col p-8 pt-16">
|
||||
<!-- Content injected by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="main-content" class="hidden flex-1 flex flex-col overflow-y-auto pb-24 scroll-smooth">
|
||||
<header class="p-6 flex justify-between items-center sticky top-0 bg-[#0F071A]/60 backdrop-blur-xl z-30 border-b border-white/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-400 to-violet-600 p-[1px] shadow-lg shadow-purple-500/10">
|
||||
<img src="logo.svg" class="w-full h-full rounded-xl object-cover mix-blend-screen opacity-80" alt="logo">
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-sm font-bold tracking-[0.4em] text-purple-100 uppercase">人生OS</h1>
|
||||
<p class="text-[8px] text-purple-400/60 tracking-widest">LIFE HARMONY v3.1</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="profile-btn" class="p-2.5 bg-white/5 rounded-full backdrop-blur-md border border-white/10 hover:bg-white/10 transition-colors group">
|
||||
<i data-lucide="user-cog" class="w-5 h-5 text-purple-200 group-active:rotate-90 transition-transform"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="page-render" class="flex-1 px-5 pt-4">
|
||||
<!-- Dynamic Pages -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<nav id="bottom-nav" class="hidden absolute bottom-0 left-0 right-0 h-20 bg-[#0F071A]/80 backdrop-blur-3xl border-t border-white/5 flex items-center justify-around px-4 z-40">
|
||||
<button data-tab="record" class="nav-item flex flex-col items-center gap-1 active">
|
||||
<i data-lucide="book-open" class="w-6 h-6"></i>
|
||||
<span class="text-[8px] uppercase tracking-widest font-bold">回溯过去</span>
|
||||
</button>
|
||||
<button data-tab="script" class="nav-item flex flex-col items-center gap-1">
|
||||
<i data-lucide="sparkles" class="w-6 h-6"></i>
|
||||
<span class="text-[8px] uppercase tracking-widest font-bold">创造未来</span>
|
||||
</button>
|
||||
<button data-tab="path" class="nav-item flex flex-col items-center gap-1">
|
||||
<i data-lucide="map" class="w-6 h-6"></i>
|
||||
<span class="text-[8px] uppercase tracking-widest font-bold">路径实现</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Global Music Player -->
|
||||
<div id="music-player" class="fixed bottom-24 right-4 z-40">
|
||||
<button id="music-toggle" class="w-11 h-11 rounded-full bg-white/5 border border-white/10 backdrop-blur-xl flex items-center justify-center shadow-lg transition-all opacity-40">
|
||||
<div id="music-disc" class="absolute inset-0 rounded-full border border-purple-500/10 pointer-events-none"></div>
|
||||
<i data-lucide="music-4" class="w-5 h-5 text-purple-300"></i>
|
||||
</button>
|
||||
<audio id="bg-music" loop>
|
||||
<source src="https://v3b.fal.media/files/b/0a8c9a0b/rStj8V-2tCe6bVYpCCcLN_output.mp3" type="audio/mpeg">
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +1,11 @@
|
||||
# 开发环境配置(本地开发调试)
|
||||
VITE_APP_ENV=dev
|
||||
# 本地环境
|
||||
VITE_API_BASE_URL=http://localhost:19089/api
|
||||
VITE_WS_URL=ws://localhost:19089/ws
|
||||
# VITE_API_BASE_URL=http://localhost:19089/api
|
||||
# VITE_WS_URL=ws://localhost:19089/ws
|
||||
# 直连后端服务(不经过 Nginx)
|
||||
VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
||||
VITE_WS_URL=ws://101.200.208.45:19089/ws
|
||||
# 测试环境
|
||||
# VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
|
||||
# VITE_WS_URL=wss://lifescript.happylifeos.com/ws
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# 测试环境配置(小程序体验版)
|
||||
VITE_APP_ENV=test
|
||||
VITE_API_BASE_URL=https://lifescript.happylifeos.com/api
|
||||
VITE_WS_URL=wss://lifescript.happylifeos.com/ws
|
||||
# 直连后端服务(不经过 Nginx)
|
||||
VITE_API_BASE_URL=http://101.200.208.45:19089/api
|
||||
VITE_WS_URL=ws://101.200.208.45:19089/ws
|
||||
VITE_DEBUG=true
|
||||
|
||||
@@ -4,15 +4,23 @@
|
||||
<!-- 分割线 -->
|
||||
<view v-if="block.type === 'hr'" class="markdown-hr"></view>
|
||||
|
||||
<text v-else-if="block.type === 'h1'" class="markdown-h1">{{ block.content }}</text>
|
||||
|
||||
<text v-else-if="block.type === 'h2'" class="markdown-h2">{{ block.content }}</text>
|
||||
|
||||
<!-- 三级标题 -->
|
||||
<text v-else-if="block.type === 'h3'" class="markdown-h3">{{ block.content }}</text>
|
||||
|
||||
<!-- 四级标题 -->
|
||||
<text v-else-if="block.type === 'h4'" class="markdown-h4">{{ block.content }}</text>
|
||||
|
||||
<view v-else-if="block.type === 'blockquote'" class="markdown-quote">
|
||||
<text>{{ block.content }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 列表项 -->
|
||||
<view v-else-if="block.type === 'li'" class="markdown-li">
|
||||
<text class="li-bullet">• </text>
|
||||
<text class="li-bullet">{{ block.marker }}</text>
|
||||
<text class="li-content">{{ block.content }}</text>
|
||||
</view>
|
||||
|
||||
@@ -53,6 +61,18 @@ const parsedBlocks = computed(() => {
|
||||
continue
|
||||
}
|
||||
|
||||
const h1Match = trimmed.match(/^#\s+(.+)/)
|
||||
if (h1Match) {
|
||||
blocks.push({ type: 'h1', content: h1Match[1] })
|
||||
continue
|
||||
}
|
||||
|
||||
const h2Match = trimmed.match(/^##\s+(.+)/)
|
||||
if (h2Match) {
|
||||
blocks.push({ type: 'h2', content: h2Match[1] })
|
||||
continue
|
||||
}
|
||||
|
||||
// 三级标题 ###
|
||||
const h3Match = trimmed.match(/^###\s+(.+)/)
|
||||
if (h3Match) {
|
||||
@@ -67,10 +87,16 @@ const parsedBlocks = computed(() => {
|
||||
continue
|
||||
}
|
||||
|
||||
// 列表项 * 或 -
|
||||
const liMatch = trimmed.match(/^[*\-]\s+(.+)/)
|
||||
const quoteMatch = trimmed.match(/^>\s+(.+)/)
|
||||
if (quoteMatch) {
|
||||
blocks.push({ type: 'blockquote', content: quoteMatch[1] })
|
||||
continue
|
||||
}
|
||||
|
||||
// 列表项 *、-、+ 或 1.
|
||||
const liMatch = trimmed.match(/^([*\-+]|\d+\.)\s+(.+)/)
|
||||
if (liMatch) {
|
||||
blocks.push({ type: 'li', content: liMatch[1] })
|
||||
blocks.push({ type: 'li', marker: /^\d+\./.test(liMatch[1]) ? liMatch[1] : '•', content: liMatch[2] })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -117,7 +143,7 @@ const parseBoldText = (text) => {
|
||||
.markdown-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
@@ -133,24 +159,52 @@ const parseBoldText = (text) => {
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.markdown-h1,
|
||||
.markdown-h2,
|
||||
.markdown-h3 {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: rgba(243, 232, 255, 0.95);
|
||||
margin: 20rpx 0 12rpx 0;
|
||||
color: rgba(248, 244, 255, 0.96);
|
||||
line-height: 1.4;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.markdown-h1 {
|
||||
font-size: 32rpx;
|
||||
margin: 4rpx 0 10rpx 0;
|
||||
}
|
||||
|
||||
.markdown-h2 {
|
||||
font-size: 30rpx;
|
||||
margin: 14rpx 0 8rpx 0;
|
||||
}
|
||||
|
||||
.markdown-h3 {
|
||||
font-size: 28rpx;
|
||||
margin: 12rpx 0 6rpx 0;
|
||||
}
|
||||
|
||||
.markdown-h4 {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: rgba(243, 232, 255, 0.95);
|
||||
margin: 16rpx 0 8rpx 0;
|
||||
margin: 10rpx 0 4rpx 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-quote {
|
||||
padding: 16rpx 18rpx;
|
||||
border-left: 5rpx solid rgba(180, 108, 255, 0.8);
|
||||
border-radius: 14rpx;
|
||||
color: rgba(218, 202, 246, 0.82);
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
}
|
||||
|
||||
.markdown-quote text {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* 列表项 */
|
||||
.markdown-li {
|
||||
display: flex;
|
||||
@@ -164,6 +218,7 @@ const parseBoldText = (text) => {
|
||||
color: #C084FC;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
min-width: 28rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const bottomPosition = ref('180rpx')
|
||||
const audioContext = null
|
||||
let audioInstance = null
|
||||
|
||||
// 背景音乐 URL - 使用原型中的音乐
|
||||
@@ -29,9 +28,18 @@ const initAudio = () => {
|
||||
audioInstance.loop = true
|
||||
audioInstance.autoplay = false
|
||||
|
||||
audioInstance.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
|
||||
audioInstance.onPause(() => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
audioInstance.onError((err) => {
|
||||
console.error('音乐播放失败:', err)
|
||||
isPlaying.value = false
|
||||
uni.showToast({ title: '音乐资源暂不可用', icon: 'none' })
|
||||
})
|
||||
|
||||
audioInstance.onEnded(() => {
|
||||
@@ -47,8 +55,7 @@ const toggleMusic = async () => {
|
||||
audioInstance.pause()
|
||||
} else {
|
||||
try {
|
||||
await audioInstance.play()
|
||||
isPlaying.value = true
|
||||
audioInstance.play()
|
||||
} catch (err) {
|
||||
console.error('音乐播放失败:', err)
|
||||
isPlaying.value = false
|
||||
|
||||
@@ -1,248 +1,821 @@
|
||||
<template>
|
||||
<view class="mine-view">
|
||||
<view class="profile-card kos-card">
|
||||
<view class="avatar">{{ avatarText }}</view>
|
||||
<view class="profile-main">
|
||||
<view class="name-row">
|
||||
<text class="name">{{ profile.nickname || 'Zoey' }}</text>
|
||||
<text class="star">✦</text>
|
||||
<view class="script-library">
|
||||
<view class="page-head">
|
||||
<view class="title-row">
|
||||
<text class="spark">✦</text>
|
||||
<text class="page-title">我的剧本</text>
|
||||
</view>
|
||||
<view class="head-actions">
|
||||
<view class="circle-btn" @click="openSearch">
|
||||
<view class="search-icon"></view>
|
||||
</view>
|
||||
<text class="signature">{{ signature }}</text>
|
||||
<view class="chips">
|
||||
<text v-for="chip in chips" :key="chip" class="chip kos-pill">{{ chip }}</text>
|
||||
<view class="circle-btn" @click="openMoreMenu">
|
||||
<view class="more-icon">
|
||||
<view></view>
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="edit-btn kos-pill" @click="editProfile">编辑资料</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card kos-card">
|
||||
<text class="stat-value">{{ eventsCount }}</text>
|
||||
<text class="stat-label">人生记录</text>
|
||||
</view>
|
||||
<view class="stat-card kos-card">
|
||||
<text class="stat-value">{{ scriptsCount }}</text>
|
||||
<text class="stat-label">生成剧本</text>
|
||||
<view class="type-tabs">
|
||||
<text
|
||||
v-for="tab in typeTabs"
|
||||
:key="tab.value"
|
||||
class="type-tab"
|
||||
:class="{ active: activeType === tab.value }"
|
||||
@click="activeType = tab.value"
|
||||
>{{ tab.label }}</text>
|
||||
<view class="new-script" @click="createScript">
|
||||
<text class="plus">+</text>
|
||||
<text>新建剧本</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-card kos-card">
|
||||
<view class="section-title">兴趣爱好</view>
|
||||
<view class="tag-cloud">
|
||||
<text v-for="tag in hobbyTags" :key="tag" class="tag kos-pill">{{ tag }}</text>
|
||||
<view class="filter-bar">
|
||||
<scroll-view class="status-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="status-row">
|
||||
<text
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.value"
|
||||
class="status-chip"
|
||||
:class="{ active: activeStatus === filter.value }"
|
||||
@click="activeStatus = filter.value"
|
||||
>{{ filter.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="sort-tools">
|
||||
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
|
||||
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
|
||||
<view v-for="i in 4" :key="i"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-card kos-card">
|
||||
<view class="section-title">生命摘要</view>
|
||||
<view class="memory-line">
|
||||
<text class="memory-label">童年</text>
|
||||
<text class="memory-text">{{ profile.childhood?.text || '还没有写下最早的光。' }}</text>
|
||||
</view>
|
||||
<view class="memory-line">
|
||||
<text class="memory-label">高光</text>
|
||||
<text class="memory-text">{{ profile.joy?.text || '等待记录一次会发光的瞬间。' }}</text>
|
||||
</view>
|
||||
<view class="memory-line">
|
||||
<text class="memory-label">未来</text>
|
||||
<text class="memory-text">{{ profile.future?.vision || '未来档案还在生成中。' }}</text>
|
||||
<view v-if="visibleScripts.length" class="script-list" :class="{ grid: viewMode === 'grid' }">
|
||||
<view
|
||||
v-for="(script, index) in visibleScripts"
|
||||
:key="script.id || index"
|
||||
class="script-card"
|
||||
@click="viewScript(script)"
|
||||
>
|
||||
<view class="cover" :class="'cover-' + (index % 6)">
|
||||
<text>{{ getInitial(script) }}</text>
|
||||
</view>
|
||||
<view class="card-main">
|
||||
<view class="card-top">
|
||||
<view class="title-wrap">
|
||||
<text class="script-title">{{ script.title }}</text>
|
||||
<text class="length-badge">{{ getLengthLabel(script.length) }}</text>
|
||||
</view>
|
||||
<view class="right-state">
|
||||
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
|
||||
<text class="ellipsis" @click.stop="openScriptMenu(script)">•••</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tag-row">
|
||||
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
|
||||
</view>
|
||||
|
||||
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
|
||||
|
||||
<view class="meta-row">
|
||||
<text>{{ getChapterCount(script) }}章</text>
|
||||
<text>|</text>
|
||||
<text>{{ getWordCount(script) }}</text>
|
||||
<text>|</text>
|
||||
<text>{{ getDateText(script) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="getStatus(script) === 'progress'" class="progress-row">
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: getProgress(script) + '%' }"></view>
|
||||
</view>
|
||||
<text>{{ getProgress(script) }}%</text>
|
||||
</view>
|
||||
<view v-else-if="isFavorite(script)" class="favorite-row">
|
||||
<text class="favorite-star">★</text>
|
||||
<text>已收藏</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="logout kos-pill" @click="handleLogout">退出登录</button>
|
||||
<view v-else class="empty-card">
|
||||
<view class="empty-book">
|
||||
<view></view>
|
||||
<view></view>
|
||||
</view>
|
||||
<text class="empty-title">还没有人生剧本</text>
|
||||
<text class="empty-text">去爽文生成页写下一句灵感,生成你的第一段平行人生。</text>
|
||||
<view class="empty-action" @click="createScript">新建剧本</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAppStore } from '../../stores/app.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const profile = computed(() => store.userProfile || store.registrationData || {})
|
||||
const eventsCount = computed(() => store.events?.length || 0)
|
||||
const scriptsCount = computed(() => store.scripts?.length || 0)
|
||||
const activeType = ref('long')
|
||||
const activeStatus = ref('all')
|
||||
const keyword = ref('')
|
||||
const sortMode = ref('updated')
|
||||
const viewMode = ref('list')
|
||||
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
||||
|
||||
const avatarText = computed(() => (profile.value.nickname || 'Z').slice(0, 1))
|
||||
const chips = computed(() => [profile.value.zodiac, profile.value.mbti, profile.value.profession].filter(Boolean))
|
||||
const hobbyTags = computed(() => {
|
||||
const hobbies = profile.value.hobbies
|
||||
if (Array.isArray(hobbies) && hobbies.length) return hobbies
|
||||
return ['阅读', '旅行', '音乐', '创作']
|
||||
const typeTabs = [
|
||||
{ label: '长篇', value: 'long' },
|
||||
{ label: '短篇', value: 'short' },
|
||||
{ label: '风格', value: 'style' }
|
||||
]
|
||||
|
||||
const statusFilters = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '进行中', value: 'progress' },
|
||||
{ label: '已完成', value: 'done' },
|
||||
{ label: '草稿箱', value: 'draft' },
|
||||
{ label: '收藏夹', value: 'favorite' }
|
||||
]
|
||||
|
||||
const fallbackScripts = [
|
||||
{
|
||||
id: 'demo-1',
|
||||
title: '逆袭人生:从低谷到巅峰',
|
||||
length: 'long',
|
||||
status: 'progress',
|
||||
tags: ['逆袭成长', '都市', '事业', '热血'],
|
||||
summary: '从被分手、被否定的低谷开始,凭借天赋、努力与智慧,一步步逆袭成为行业巅峰,收获事业、财富...',
|
||||
chapterCount: 28,
|
||||
wordCount: 128000,
|
||||
updatedAt: '今天 21:30',
|
||||
progress: 28
|
||||
},
|
||||
{
|
||||
id: 'demo-2',
|
||||
title: '如果那年我没有放弃',
|
||||
length: 'long',
|
||||
status: 'done',
|
||||
tags: ['成长治愈', '校园', '爱情', '温暖'],
|
||||
summary: '重回十八岁,弥补遗憾,勇敢追梦,守护那些曾经错过的人和事。',
|
||||
chapterCount: 36,
|
||||
wordCount: 156000,
|
||||
completedAt: '2025.05.10',
|
||||
isFavorite: true
|
||||
},
|
||||
{
|
||||
id: 'demo-3',
|
||||
title: '重生之我在未来等你',
|
||||
length: 'long',
|
||||
status: 'progress',
|
||||
tags: ['重生', '科幻', '爱情', '未来'],
|
||||
summary: '一觉醒来,回到十年前的那一天。这一次,我不仅要改变自己的人生,还要找到你。',
|
||||
chapterCount: 18,
|
||||
wordCount: 83000,
|
||||
updatedAt: '昨天 18:47',
|
||||
progress: 46
|
||||
},
|
||||
{
|
||||
id: 'demo-4',
|
||||
title: '天才作曲家的璀璨之路',
|
||||
length: 'long',
|
||||
status: 'draft',
|
||||
tags: ['音乐', '励志', '天赋', '梦想'],
|
||||
summary: '从默默无闻到享誉全球,用音符征服世界,写下属于自己的传奇乐章。',
|
||||
chapterCount: 9,
|
||||
wordCount: 31000,
|
||||
createdAt: '2025.05.08'
|
||||
},
|
||||
{
|
||||
id: 'demo-5',
|
||||
title: '咖啡馆里的奇遇',
|
||||
length: 'short',
|
||||
status: 'done',
|
||||
tags: ['生活', '治愈', '奇幻', '温暖'],
|
||||
summary: '一杯咖啡,一次奇遇,改变了我平凡的生活,也让我遇见了最特别的你。',
|
||||
chapterCount: 1,
|
||||
wordCount: 23000,
|
||||
completedAt: '2025.05.01',
|
||||
isFavorite: true
|
||||
},
|
||||
{
|
||||
id: 'demo-6',
|
||||
title: '赛博时代的追光者',
|
||||
length: 'long',
|
||||
status: 'draft',
|
||||
tags: ['科幻', '未来', '冒险', '热血'],
|
||||
summary: '在数据与代码构建的世界里,我追寻光明,也在黑暗中寻找真正的自由。',
|
||||
chapterCount: 3,
|
||||
wordCount: 12000,
|
||||
createdAt: '2025.05.12'
|
||||
}
|
||||
]
|
||||
|
||||
const scripts = computed(() => {
|
||||
const list = store.scripts || []
|
||||
return list.length ? list : fallbackScripts
|
||||
})
|
||||
const signature = computed(() => profile.value.future?.ideal || '正在把人生整理成一份会发光的档案。')
|
||||
|
||||
const editProfile = () => {
|
||||
uni.navigateTo({ url: '/pages/onboarding/index?edit=1' })
|
||||
const visibleScripts = computed(() => {
|
||||
const filtered = scripts.value.filter(script => {
|
||||
const status = getStatus(script)
|
||||
if (keyword.value) {
|
||||
const haystack = [script.title, script.summary, script.content, script.style, ...(script.tags || [])].join(' ')
|
||||
if (!haystack.includes(keyword.value)) return false
|
||||
}
|
||||
if (activeStatus.value === 'favorite') return isFavorite(script)
|
||||
if (activeStatus.value !== 'all' && status !== activeStatus.value) return false
|
||||
if (activeType.value === 'short') return script.length === 'short'
|
||||
if (activeType.value === 'long') return script.length !== 'short'
|
||||
return true
|
||||
})
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (sortMode.value === 'words') return Number(b.wordCount || 0) - Number(a.wordCount || 0)
|
||||
if (sortMode.value === 'progress') return getProgress(b) - getProgress(a)
|
||||
return String(b.updateTime || b.updatedAt || b.createTime || b.date || '').localeCompare(String(a.updateTime || a.updatedAt || a.createTime || a.date || ''))
|
||||
})
|
||||
})
|
||||
|
||||
const sortLabel = computed(() => {
|
||||
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
|
||||
return map[sortMode.value] || '最近更新⌄'
|
||||
})
|
||||
|
||||
const getStatus = (script) => {
|
||||
if (script.status) return script.status
|
||||
if (script.isDraft) return 'draft'
|
||||
if (script.isCompleted || script.completedAt) return 'done'
|
||||
return script.progress ? 'progress' : 'done'
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
const getStatusLabel = (script) => {
|
||||
const map = { progress: '进行中', done: '已完成', draft: '草稿' }
|
||||
return map[getStatus(script)] || '已完成'
|
||||
}
|
||||
|
||||
const getLengthLabel = (length) => {
|
||||
return length === 'short' ? '短篇' : '长篇'
|
||||
}
|
||||
|
||||
const getTags = (script) => {
|
||||
if (Array.isArray(script.tags) && script.tags.length) return script.tags.slice(0, 4)
|
||||
return [script.style || '逆袭成长', '都市', '事业', '热血']
|
||||
}
|
||||
|
||||
const getChapterCount = (script) => script.chapterCount || script.chapters || Math.max(1, Math.round((script.wordCount || 30000) / 4500))
|
||||
|
||||
const getWordCount = (script) => {
|
||||
const count = Number(script.wordCount || 0)
|
||||
if (!count) return '3.1万字'
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(1)}万字`
|
||||
return `${count}字`
|
||||
}
|
||||
|
||||
const getDateText = (script) => {
|
||||
if (getStatus(script) === 'done') return `完成于:${script.completedAt || script.date || '2025.05.10'}`
|
||||
if (getStatus(script) === 'draft') return `创建于:${script.createdAt || script.date || '2025.05.08'}`
|
||||
return `最近更新:${script.updatedAt || script.date || '今天 21:30'}`
|
||||
}
|
||||
|
||||
const getProgress = (script) => Math.max(1, Math.min(99, Number(script.progress || 28)))
|
||||
|
||||
const getInitial = (script) => (script.title || '剧').slice(0, 1)
|
||||
|
||||
const isFavorite = (script) => {
|
||||
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
|
||||
}
|
||||
|
||||
const viewScript = (script) => {
|
||||
if (!script?.id || String(script.id).startsWith('demo-')) return
|
||||
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
|
||||
}
|
||||
|
||||
const createScript = () => {
|
||||
uni.$emit('switchTab', 'script')
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要离开当前数字生命档案吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
await store.logout()
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
title: '搜索剧本',
|
||||
editable: true,
|
||||
placeholderText: '输入标题、标签或关键词',
|
||||
success: (res) => {
|
||||
if (res.confirm) keyword.value = String(res.content || '').trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openMoreMenu = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['清空搜索', '只看收藏', '查看全部'],
|
||||
success: ({ tapIndex }) => {
|
||||
if (tapIndex === 0) keyword.value = ''
|
||||
if (tapIndex === 1) activeStatus.value = 'favorite'
|
||||
if (tapIndex === 2) {
|
||||
keyword.value = ''
|
||||
activeStatus.value = 'all'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSort = () => {
|
||||
const order = ['updated', 'words', 'progress']
|
||||
sortMode.value = order[(order.indexOf(sortMode.value) + 1) % order.length]
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
|
||||
}
|
||||
|
||||
const openScriptMenu = (script) => {
|
||||
const favorite = isFavorite(script)
|
||||
uni.showActionSheet({
|
||||
itemList: [favorite ? '取消收藏' : '收藏剧本', '查看详情', '映射路径'],
|
||||
success: ({ tapIndex }) => {
|
||||
if (tapIndex === 0) {
|
||||
const next = { ...localFavorites.value }
|
||||
if (favorite) delete next[String(script.id)]
|
||||
else next[String(script.id)] = true
|
||||
localFavorites.value = next
|
||||
uni.setStorageSync('script_favorites', next)
|
||||
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
||||
}
|
||||
if (tapIndex === 1) viewScript(script)
|
||||
if (tapIndex === 2) mapScript(script)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mapScript = async (script) => {
|
||||
if (!script?.id || String(script.id).startsWith('demo-')) {
|
||||
uni.showToast({ title: '示例剧本暂不可映射', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const res = await store.selectScript(script.id)
|
||||
if (!res.success) {
|
||||
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/main/PathView' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mine-view {
|
||||
.script-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding-bottom: 32rpx;
|
||||
padding-bottom: 26rpx;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
border-radius: 34rpx;
|
||||
padding: 32rpx;
|
||||
.page-head,
|
||||
.title-row,
|
||||
.head-actions,
|
||||
.type-tabs,
|
||||
.filter-bar,
|
||||
.sort-tools,
|
||||
.card-top,
|
||||
.title-wrap,
|
||||
.right-state,
|
||||
.meta-row,
|
||||
.progress-row,
|
||||
.favorite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
flex-shrink: 0;
|
||||
.page-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.spark {
|
||||
color: #ffd58c;
|
||||
font-size: 34rpx;
|
||||
text-shadow: 0 0 20rpx rgba(255, 202, 125, 0.5);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: #fff;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.circle-btn {
|
||||
width: 58rpx;
|
||||
height: 58rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 50rpx;
|
||||
border: 1rpx solid rgba(142, 105, 255, 0.36);
|
||||
background: rgba(10, 11, 38, 0.72);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 25rpx;
|
||||
height: 25rpx;
|
||||
border: 4rpx solid #fff;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -9rpx;
|
||||
bottom: -8rpx;
|
||||
width: 14rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
display: flex;
|
||||
gap: 5rpx;
|
||||
}
|
||||
|
||||
.more-icon view {
|
||||
width: 6rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
justify-content: space-between;
|
||||
border-bottom: 1rpx solid rgba(126, 87, 255, 0.18);
|
||||
padding-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
position: relative;
|
||||
color: rgba(224, 214, 243, 0.7);
|
||||
font-size: 31rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #b245ff, #2a7dff);
|
||||
box-shadow: 0 0 36rpx rgba(168, 85, 255, 0.55);
|
||||
padding: 0 20rpx 14rpx;
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.type-tab.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
.type-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
right: 20rpx;
|
||||
bottom: -17rpx;
|
||||
height: 5rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #b246ff;
|
||||
box-shadow: 0 0 18rpx rgba(178, 70, 255, 0.8);
|
||||
}
|
||||
|
||||
.new-script {
|
||||
margin-left: auto;
|
||||
height: 64rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.name {
|
||||
gap: 8rpx;
|
||||
color: #fff;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #ffd184;
|
||||
}
|
||||
|
||||
.signature {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: rgba(224, 211, 246, 0.66);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #b346ff, #7330ff);
|
||||
box-shadow: 0 0 26rpx rgba(168, 85, 247, 0.54);
|
||||
}
|
||||
|
||||
.chips,
|
||||
.tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
.plus {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.tag {
|
||||
height: 44rpx;
|
||||
padding: 0 16rpx;
|
||||
.filter-bar {
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.status-scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
height: 52rpx;
|
||||
min-width: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgba(244, 235, 255, 0.86);
|
||||
font-size: 21rpx;
|
||||
justify-content: center;
|
||||
color: rgba(224, 214, 243, 0.78);
|
||||
font-size: 23rpx;
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
align-self: flex-start;
|
||||
height: 54rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #caa0ff;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
.status-chip.active {
|
||||
color: #fff;
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
border-color: rgba(206, 82, 255, 0.92);
|
||||
background: rgba(130, 48, 220, 0.42);
|
||||
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.46);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: rgba(224, 211, 246, 0.62);
|
||||
.sort-tools {
|
||||
gap: 14rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-text {
|
||||
color: #c99fff;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 30rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.memory-line {
|
||||
.grid-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 18rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 86rpx 1fr;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6rpx;
|
||||
padding: 11rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.32);
|
||||
}
|
||||
|
||||
.grid-icon view {
|
||||
border: 2rpx solid rgba(230, 222, 250, 0.78);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
|
||||
.script-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid rgba(180, 139, 255, 0.14);
|
||||
}
|
||||
|
||||
.memory-line:last-child {
|
||||
border-bottom: 0;
|
||||
.script-list.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.memory-label {
|
||||
color: #b56cff;
|
||||
.script-list.grid .script-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-list.grid .cover {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-icon.active {
|
||||
border-color: rgba(206, 82, 255, 0.9);
|
||||
background: rgba(130, 48, 220, 0.28);
|
||||
}
|
||||
|
||||
.script-card {
|
||||
display: grid;
|
||||
grid-template-columns: 150rpx 1fr;
|
||||
gap: 22rpx;
|
||||
min-height: 196rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 1rpx solid rgba(105, 79, 210, 0.34);
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(112, 72, 255, 0.14), transparent 38%),
|
||||
rgba(9, 12, 42, 0.72);
|
||||
box-shadow: inset 0 0 28rpx rgba(92, 57, 197, 0.08);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 54rpx;
|
||||
font-weight: 900;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #3b1a90, #d65cff);
|
||||
}
|
||||
|
||||
.cover-0 { background: linear-gradient(135deg, #29135f, #9037ff 48%, #191b5e); }
|
||||
.cover-1 { background: linear-gradient(135deg, #3c1c4a, #f2b3cc 48%, #16143b); }
|
||||
.cover-2 { background: linear-gradient(135deg, #1a225f, #7d4cff 48%, #0a0f2c); }
|
||||
.cover-3 { background: linear-gradient(135deg, #2f240b, #f7b44a 48%, #0d0a16); }
|
||||
.cover-4 { background: linear-gradient(135deg, #3f2417, #d8b58a 48%, #17101d); }
|
||||
.cover-5 { background: linear-gradient(135deg, #141451, #cc46ff 48%, #0c0b28); }
|
||||
|
||||
.card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
justify-content: space-between;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
min-width: 0;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.script-title {
|
||||
color: #fff;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 900;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.length-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4rpx 9rpx;
|
||||
border-radius: 8rpx;
|
||||
color: #c985ff;
|
||||
font-size: 18rpx;
|
||||
border: 1rpx solid rgba(182, 92, 255, 0.5);
|
||||
background: rgba(128, 55, 204, 0.22);
|
||||
}
|
||||
|
||||
.right-state {
|
||||
flex-shrink: 0;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.state-pill {
|
||||
height: 34rpx;
|
||||
padding: 0 14rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 19rpx;
|
||||
}
|
||||
|
||||
.state-progress {
|
||||
color: #ffbf4c;
|
||||
background: rgba(170, 103, 20, 0.22);
|
||||
}
|
||||
|
||||
.state-done {
|
||||
color: #79e6a9;
|
||||
background: rgba(44, 146, 88, 0.2);
|
||||
}
|
||||
|
||||
.state-draft {
|
||||
color: rgba(224, 214, 243, 0.76);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
color: rgba(224, 214, 243, 0.66);
|
||||
font-size: 24rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
height: 34rpx;
|
||||
padding: 0 14rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #d49cff;
|
||||
font-size: 19rpx;
|
||||
background: rgba(149, 55, 255, 0.2);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: -webkit-box;
|
||||
margin-top: 14rpx;
|
||||
color: rgba(226, 215, 246, 0.72);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
gap: 14rpx;
|
||||
margin-top: 14rpx;
|
||||
color: rgba(218, 204, 243, 0.66);
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
justify-content: flex-end;
|
||||
gap: 14rpx;
|
||||
margin-top: 14rpx;
|
||||
color: #bd72ff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.memory-text {
|
||||
color: rgba(224, 211, 246, 0.72);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
.progress-track {
|
||||
width: 118rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(173, 160, 210, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logout {
|
||||
height: 72rpx;
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #b246ff, #d878ff);
|
||||
}
|
||||
|
||||
.favorite-row {
|
||||
justify-content: flex-end;
|
||||
gap: 8rpx;
|
||||
margin-top: 14rpx;
|
||||
color: #b768ff;
|
||||
font-size: 23rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.favorite-star {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
margin-top: 30rpx;
|
||||
border-radius: 26rpx;
|
||||
padding: 44rpx 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border: 1rpx solid rgba(105, 79, 210, 0.34);
|
||||
background: rgba(9, 12, 42, 0.72);
|
||||
}
|
||||
|
||||
.empty-book {
|
||||
display: flex;
|
||||
gap: 6rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.empty-book view {
|
||||
width: 32rpx;
|
||||
height: 46rpx;
|
||||
border: 4rpx solid #b768ff;
|
||||
border-radius: 8rpx 4rpx 4rpx 8rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin-top: 12rpx;
|
||||
color: rgba(226, 215, 246, 0.68);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: 22rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 30rpx;
|
||||
border-radius: 999rpx;
|
||||
color: rgba(224, 211, 246, 0.74);
|
||||
font-size: 25rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
font-size: 23rpx;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #b346ff, #7330ff);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,10 +63,42 @@ const loadPath = async (scriptId) => {
|
||||
if (!scriptId) return
|
||||
try {
|
||||
const res = await lifePathService.getPathByScriptId(scriptId)
|
||||
pathData.value = res.data || null
|
||||
pathData.value = res.data || await createPlaceholderPath(scriptId)
|
||||
store.setCurrentPath(pathData.value)
|
||||
} catch (error) {
|
||||
pathData.value = null
|
||||
pathData.value = await createPlaceholderPath(scriptId)
|
||||
store.setCurrentPath(pathData.value)
|
||||
}
|
||||
}
|
||||
|
||||
const createPlaceholderPath = async (scriptId) => {
|
||||
const script = selectedScript.value || {}
|
||||
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
|
||||
const steps = [
|
||||
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
|
||||
{ phase: '阶段2', task: '建立习惯', desc: '选择一个最小行动,每天稳定推进。', content: '选择一个最小行动,每天稳定推进。', done: false },
|
||||
{ phase: '阶段3', task: '复盘迭代', desc: '每周回看进展,根据现实反馈调整路径。', content: '每周回看进展,根据现实反馈调整路径。', done: false }
|
||||
]
|
||||
try {
|
||||
const res = await lifePathService.createPath({
|
||||
scriptId,
|
||||
title,
|
||||
description: script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
|
||||
steps,
|
||||
progress: 8,
|
||||
status: 'active'
|
||||
})
|
||||
return lifePathService.transformToFrontendFormat(res.data)
|
||||
} catch (error) {
|
||||
return {
|
||||
id: `local-${scriptId}`,
|
||||
scriptId,
|
||||
title,
|
||||
description: script.summary || '占位实现路径',
|
||||
steps,
|
||||
progress: 8,
|
||||
status: 'active'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</view>
|
||||
<text class="section-subtitle">你的成长之路,正在展开</text>
|
||||
</view>
|
||||
<view class="map-btn kos-pill">
|
||||
<view class="map-btn kos-pill" @click="openMap">
|
||||
<view class="map-icon"></view>
|
||||
<text>轨迹地图</text>
|
||||
</view>
|
||||
@@ -81,7 +81,7 @@
|
||||
:class="{ active: activeFilter === filter.value }"
|
||||
@click="activeFilter = filter.value"
|
||||
>{{ filter.label }}</text>
|
||||
<text class="add-filter">+</text>
|
||||
<text class="add-filter" @click="addFilter">+</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -130,14 +130,16 @@ import { useAppStore } from '../../stores/app.js'
|
||||
|
||||
const store = useAppStore()
|
||||
const activeFilter = ref('all')
|
||||
const customFilters = ref([])
|
||||
|
||||
const filters = [
|
||||
const baseFilters = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '童年', value: 'childhood' },
|
||||
{ label: '高光', value: 'highlight' },
|
||||
{ label: '低谷', value: 'valley' },
|
||||
{ label: '美好的瞬间', value: 'daily_log' }
|
||||
]
|
||||
const filters = computed(() => [...baseFilters, ...customFilters.value])
|
||||
|
||||
const sampleEvents = [
|
||||
{
|
||||
@@ -192,6 +194,10 @@ const avatar = computed(() => {
|
||||
|
||||
const displayEvents = computed(() => {
|
||||
if (activeFilter.value === 'all') return events.value
|
||||
if (activeFilter.value.startsWith('custom_')) {
|
||||
const label = activeFilter.value.replace('custom_', '')
|
||||
return events.value.filter(event => Array.isArray(event.tags) && event.tags.includes(label))
|
||||
}
|
||||
return events.value.filter(event => event.eventType === activeFilter.value || event.emotionType === activeFilter.value)
|
||||
})
|
||||
|
||||
@@ -222,28 +228,50 @@ const createEvent = () => {
|
||||
}
|
||||
|
||||
const openDetail = (event) => {
|
||||
if (String(event.id || '').startsWith('sample-')) return
|
||||
uni.setStorageSync('current_life_event', JSON.parse(JSON.stringify(event || {})))
|
||||
if (!event?.id) return
|
||||
uni.navigateTo({ url: `/pages/life-event/detail?id=${event.id}` })
|
||||
}
|
||||
|
||||
const editProfile = () => {
|
||||
uni.navigateTo({ url: '/pages/onboarding/index?edit=1' })
|
||||
}
|
||||
|
||||
const openMap = () => {
|
||||
uni.navigateTo({ url: '/pages/main/PathView' })
|
||||
}
|
||||
|
||||
const addFilter = () => {
|
||||
uni.showModal({
|
||||
title: '新增筛选',
|
||||
editable: true,
|
||||
placeholderText: '输入标签名',
|
||||
success: (res) => {
|
||||
const value = String(res.content || '').trim()
|
||||
if (!res.confirm || !value) return
|
||||
const filter = { label: value, value: `custom_${value}` }
|
||||
if (!customFilters.value.some(item => item.label === value)) {
|
||||
customFilters.value.push(filter)
|
||||
}
|
||||
activeFilter.value = filter.value
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
padding-bottom: 34rpx;
|
||||
gap: 24rpx;
|
||||
padding-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 34rpx;
|
||||
padding: 40rpx 34rpx 32rpx;
|
||||
padding: 34rpx 30rpx 28rpx;
|
||||
}
|
||||
|
||||
.profile-card::after {
|
||||
@@ -251,8 +279,8 @@ const editProfile = () => {
|
||||
position: absolute;
|
||||
right: -28rpx;
|
||||
bottom: -20rpx;
|
||||
width: 250rpx;
|
||||
height: 180rpx;
|
||||
width: 220rpx;
|
||||
height: 156rpx;
|
||||
background: radial-gradient(circle, rgba(122, 58, 255, 0.35), transparent 62%);
|
||||
border: 1rpx solid rgba(158, 88, 255, 0.26);
|
||||
border-radius: 50%;
|
||||
@@ -269,18 +297,18 @@ const editProfile = () => {
|
||||
.profile-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 134rpx;
|
||||
height: 134rpx;
|
||||
padding: 6rpx;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
padding: 5rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fff, #9b54ff 38%, #4a67ff);
|
||||
box-shadow: 0 0 42rpx rgba(149, 89, 255, 0.56);
|
||||
box-shadow: 0 0 34rpx rgba(149, 89, 255, 0.52);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@@ -292,10 +320,10 @@ const editProfile = () => {
|
||||
|
||||
.avatar-edit {
|
||||
position: absolute;
|
||||
right: -6rpx;
|
||||
bottom: -6rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
right: -5rpx;
|
||||
bottom: -5rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #8b4dff, #4a2cff);
|
||||
box-shadow: 0 0 20rpx rgba(158, 91, 255, 0.6);
|
||||
@@ -308,7 +336,7 @@ const editProfile = () => {
|
||||
border-radius: 6rpx;
|
||||
background: #fff;
|
||||
transform: rotate(-45deg);
|
||||
margin: 21rpx auto;
|
||||
margin: 17rpx auto;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
@@ -324,33 +352,33 @@ const editProfile = () => {
|
||||
|
||||
.profile-name {
|
||||
color: #fff;
|
||||
font-size: 44rpx;
|
||||
font-size: 36rpx;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #ffd589;
|
||||
font-size: 30rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.profile-subtitle {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
margin-top: 10rpx;
|
||||
color: rgba(239, 232, 255, 0.78);
|
||||
font-size: 27rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.edit-profile {
|
||||
flex-shrink: 0;
|
||||
height: 64rpx;
|
||||
padding: 0 22rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
gap: 8rpx;
|
||||
color: #dccbff;
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.tiny-pen {
|
||||
@@ -363,12 +391,12 @@ const editProfile = () => {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 1rpx;
|
||||
margin: 32rpx 0 24rpx;
|
||||
margin: 28rpx 0 20rpx;
|
||||
background: rgba(180, 139, 255, 0.22);
|
||||
}
|
||||
|
||||
.profile-divider.small {
|
||||
margin: 24rpx 0;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
@@ -380,23 +408,28 @@ const editProfile = () => {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding-right: 18rpx;
|
||||
gap: 10rpx;
|
||||
padding-right: 12rpx;
|
||||
border-right: 1rpx solid rgba(180, 139, 255, 0.22);
|
||||
}
|
||||
|
||||
.meta-item + .meta-item {
|
||||
padding-left: 22rpx;
|
||||
padding-left: 16rpx;
|
||||
}
|
||||
|
||||
.meta-item.no-border {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.meta-item > view,
|
||||
.hobby-row > view {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-icon,
|
||||
.heart {
|
||||
color: #a855ff;
|
||||
font-size: 42rpx;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 24rpx rgba(168, 85, 255, 0.7);
|
||||
}
|
||||
@@ -404,35 +437,35 @@ const editProfile = () => {
|
||||
.person-icon,
|
||||
.job-icon {
|
||||
position: relative;
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.person-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
left: 8rpx;
|
||||
top: 0;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border: 4rpx solid #a855ff;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border: 3rpx solid #a855ff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.person-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2rpx;
|
||||
left: 1rpx;
|
||||
bottom: 0;
|
||||
width: 30rpx;
|
||||
height: 18rpx;
|
||||
border: 4rpx solid #a855ff;
|
||||
width: 28rpx;
|
||||
height: 16rpx;
|
||||
border: 3rpx solid #a855ff;
|
||||
border-radius: 18rpx 18rpx 4rpx 4rpx;
|
||||
}
|
||||
|
||||
.job-icon {
|
||||
border: 5rpx solid #a855ff;
|
||||
border: 4rpx solid #a855ff;
|
||||
border-radius: 8rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -440,11 +473,11 @@ const editProfile = () => {
|
||||
.job-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: -10rpx;
|
||||
left: 8rpx;
|
||||
top: -8rpx;
|
||||
width: 12rpx;
|
||||
height: 8rpx;
|
||||
border: 4rpx solid #a855ff;
|
||||
height: 7rpx;
|
||||
border: 3rpx solid #a855ff;
|
||||
border-bottom: 0;
|
||||
border-radius: 8rpx 8rpx 0 0;
|
||||
}
|
||||
@@ -457,14 +490,14 @@ const editProfile = () => {
|
||||
|
||||
.meta-label {
|
||||
color: rgba(219, 204, 247, 0.54);
|
||||
font-size: 22rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.meta-value,
|
||||
.hobby-text {
|
||||
margin-top: 4rpx;
|
||||
margin-top: 3rpx;
|
||||
color: #fff;
|
||||
font-size: 27rpx;
|
||||
font-size: 23rpx;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -474,7 +507,7 @@ const editProfile = () => {
|
||||
.hobby-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
gap: 22rpx;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
@@ -486,12 +519,12 @@ const editProfile = () => {
|
||||
.title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 44rpx;
|
||||
font-size: 40rpx;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -502,26 +535,26 @@ const editProfile = () => {
|
||||
|
||||
.section-subtitle {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
margin-top: 10rpx;
|
||||
color: rgba(210, 194, 242, 0.68);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.map-btn {
|
||||
height: 64rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
color: #caa9ff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.map-btn {
|
||||
height: 56rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
color: #caa9ff;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.map-icon {
|
||||
width: 28rpx;
|
||||
height: 22rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
width: 24rpx;
|
||||
height: 20rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 4rpx;
|
||||
transform: skewY(-12deg);
|
||||
}
|
||||
@@ -533,21 +566,21 @@ const editProfile = () => {
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
gap: 18rpx;
|
||||
gap: 16rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-chip,
|
||||
.add-filter {
|
||||
height: 62rpx;
|
||||
min-width: 104rpx;
|
||||
padding: 0 30rpx;
|
||||
height: 54rpx;
|
||||
min-width: 92rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(224, 214, 243, 0.72);
|
||||
font-size: 25rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -559,11 +592,11 @@ const editProfile = () => {
|
||||
}
|
||||
|
||||
.add-filter {
|
||||
min-width: 62rpx;
|
||||
min-width: 56rpx;
|
||||
padding: 0;
|
||||
border: 2rpx dashed rgba(155, 112, 255, 0.45);
|
||||
color: #c49cff;
|
||||
font-size: 36rpx;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
@@ -573,8 +606,8 @@ const editProfile = () => {
|
||||
|
||||
.timeline-item {
|
||||
display: grid;
|
||||
grid-template-columns: 88rpx 1fr;
|
||||
min-height: 188rpx;
|
||||
grid-template-columns: 76rpx 1fr;
|
||||
min-height: 170rpx;
|
||||
}
|
||||
|
||||
.rail {
|
||||
@@ -586,19 +619,19 @@ const editProfile = () => {
|
||||
.node {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4rpx solid currentColor;
|
||||
box-shadow: 0 0 28rpx currentColor;
|
||||
box-shadow: 0 0 24rpx currentColor;
|
||||
}
|
||||
|
||||
.node view {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
@@ -610,7 +643,7 @@ const editProfile = () => {
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
top: 56rpx;
|
||||
top: 50rpx;
|
||||
bottom: -6rpx;
|
||||
width: 4rpx;
|
||||
background: linear-gradient(180deg, currentColor, rgba(255,255,255,0.14));
|
||||
@@ -618,14 +651,14 @@ const editProfile = () => {
|
||||
}
|
||||
|
||||
.event-card {
|
||||
min-height: 160rpx;
|
||||
margin-bottom: 22rpx;
|
||||
min-height: 146rpx;
|
||||
margin-bottom: 18rpx;
|
||||
border-radius: 28rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 116rpx 1rpx 1fr 108rpx 24rpx;
|
||||
grid-template-columns: 96rpx 1rpx 1fr 88rpx 20rpx;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
padding: 22rpx;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.date-box {
|
||||
@@ -635,16 +668,16 @@ const editProfile = () => {
|
||||
.date-month,
|
||||
.date-age {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.date-age {
|
||||
margin-top: 8rpx;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.event-divider {
|
||||
width: 1rpx;
|
||||
height: 92rpx;
|
||||
height: 82rpx;
|
||||
background: rgba(180, 139, 255, 0.18);
|
||||
}
|
||||
|
||||
@@ -655,17 +688,17 @@ const editProfile = () => {
|
||||
.event-title {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-size: 27rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.event-tag {
|
||||
display: inline-flex;
|
||||
margin-top: 14rpx;
|
||||
padding: 6rpx 14rpx;
|
||||
margin-top: 10rpx;
|
||||
padding: 5rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
font-size: 19rpx;
|
||||
}
|
||||
|
||||
.tag-0 { color: #8effc7; background: rgba(50, 196, 128, 0.18); }
|
||||
@@ -675,9 +708,9 @@ const editProfile = () => {
|
||||
|
||||
.event-summary {
|
||||
display: -webkit-box;
|
||||
margin-top: 12rpx;
|
||||
margin-top: 9rpx;
|
||||
color: rgba(226, 215, 246, 0.66);
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -685,14 +718,14 @@ const editProfile = () => {
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 22rpx;
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 44rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -704,7 +737,7 @@ const editProfile = () => {
|
||||
|
||||
.chevron {
|
||||
color: rgba(223, 213, 245, 0.68);
|
||||
font-size: 54rpx;
|
||||
font-size: 46rpx;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
@@ -733,20 +766,20 @@ const editProfile = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
gap: 8rpx;
|
||||
color: #caa6ff;
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.plus-core {
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 56rpx;
|
||||
font-size: 46rpx;
|
||||
background: linear-gradient(135deg, #b348ff, #582cff);
|
||||
box-shadow: 0 0 38rpx rgba(171, 72, 255, 0.62);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
<view class="mode-tabs kos-card">
|
||||
<view class="mode-tab" :class="{ active: mode === 'inspiration' }" @click="mode = 'inspiration'">
|
||||
<text class="tab-icon">✦</text>
|
||||
<view class="tab-icon sparkles-mini">
|
||||
<view class="spark main"></view>
|
||||
<view class="spark dot"></view>
|
||||
</view>
|
||||
<view>
|
||||
<text class="tab-title">灵感模式</text>
|
||||
<text class="tab-sub">一句话生成爽文</text>
|
||||
@@ -34,37 +37,37 @@
|
||||
<view v-if="mode === 'inspiration'" class="prompt-panel kos-card">
|
||||
<view class="panel-head">
|
||||
<view class="panel-title-row">
|
||||
<text class="spark">✦</text>
|
||||
<view class="panel-sparkles">
|
||||
<view class="panel-spark large"></view>
|
||||
<view class="panel-spark small"></view>
|
||||
</view>
|
||||
<text class="panel-title">写下你想要的故事设定或人生想法</text>
|
||||
</view>
|
||||
<view class="help-chip" @click="shuffleInspirations">不知道写什么?</view>
|
||||
<view class="help-chip" @click="shuffleInspirations">
|
||||
<view class="bulb-icon"></view>
|
||||
<text>不知道写什么?</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<textarea
|
||||
class="prompt-box"
|
||||
maxlength="500"
|
||||
v-model="prompt"
|
||||
placeholder="例如: “如果我没有分手,现在会怎样?” “我成为顶级作曲家的人生” “重生回18岁改变一切” “从小镇做题家到世界首富”"
|
||||
:placeholder="promptPlaceholder"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
|
||||
<view class="prompt-tools">
|
||||
<view class="tool-pill kos-pill">语音输入</view>
|
||||
<text class="counter">{{ prompt.length }}/500</text>
|
||||
<view class="tool-pill kos-pill" @click="shuffleInspirations">随机灵感</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="style-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="style-row">
|
||||
<text
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
class="style-chip kos-pill"
|
||||
:class="{ active: style === item.value }"
|
||||
@click="style = item.value"
|
||||
>{{ item.label }}</text>
|
||||
<view class="tool-pill kos-pill" @click="handleVoiceInput">
|
||||
<view class="mic-icon"></view>
|
||||
<text>语音输入</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text class="counter">{{ prompt.length }}/500</text>
|
||||
<view class="tool-pill kos-pill" @click="shuffleInspirations">
|
||||
<view class="refresh-icon"></view>
|
||||
<text>随机灵感</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="generate-btn kos-primary" :disabled="generating || !prompt.trim()" :loading="generating" @click="generateByPrompt">
|
||||
<view>
|
||||
@@ -72,7 +75,11 @@
|
||||
<text class="generate-sub">今日剩余生成次数:{{ remainingCount }}次</text>
|
||||
</view>
|
||||
<view class="planet-badge">
|
||||
<view></view>
|
||||
<view class="planet-face">
|
||||
<view class="planet-eye left"></view>
|
||||
<view class="planet-eye right"></view>
|
||||
<view class="planet-mouth"></view>
|
||||
</view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
@@ -171,6 +178,13 @@ const generating = ref(false)
|
||||
const remainingCount = ref(3)
|
||||
const style = ref('career')
|
||||
const randomRecommendations = ref([])
|
||||
const promptPlaceholder = [
|
||||
'例如:',
|
||||
'“如果我没有分手,现在会怎样?”',
|
||||
'“我成为顶级作曲家的人生”',
|
||||
'“重生回18岁改变一切”',
|
||||
'“从小镇做题家到世界首富”'
|
||||
].join('\n')
|
||||
const custom = reactive({
|
||||
theme: '',
|
||||
style: 'career',
|
||||
@@ -249,6 +263,7 @@ const generateByPrompt = async () => {
|
||||
}
|
||||
|
||||
prompt.value = ''
|
||||
if (typeof res.data?.remainingCount === 'number') remainingCount.value = res.data.remainingCount
|
||||
mode.value = 'list'
|
||||
uni.showToast({ title: '剧本已生成', icon: 'success' })
|
||||
}
|
||||
@@ -286,14 +301,28 @@ const selectScript = async (id) => {
|
||||
}
|
||||
uni.navigateTo({ url: '/pages/main/PathView' })
|
||||
}
|
||||
|
||||
const handleVoiceInput = () => {
|
||||
uni.showModal({
|
||||
title: '语音输入',
|
||||
content: '语音识别入口已保留。后续接入微信录音和AI语音转文字后,会把识别结果自动填入灵感输入框。',
|
||||
confirmText: '填入示例',
|
||||
cancelText: '知道了',
|
||||
success: (res) => {
|
||||
if (res.confirm && !prompt.value.trim()) {
|
||||
prompt.value = '如果我能重新选择一次,我想把人生过成更勇敢的版本'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
padding-bottom: 32rpx;
|
||||
gap: 24rpx;
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
@@ -313,7 +342,7 @@ const selectScript = async (id) => {
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 46rpx;
|
||||
font-size: 40rpx;
|
||||
line-height: 1.08;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -326,23 +355,24 @@ const selectScript = async (id) => {
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
margin-top: 14rpx;
|
||||
color: rgba(219, 203, 247, 0.74);
|
||||
font-size: 27rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.script-list-btn,
|
||||
.help-chip,
|
||||
.tool-pill,
|
||||
.rewrite-btn {
|
||||
height: 60rpx;
|
||||
padding: 0 22rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #caa0ff;
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
gap: 9rpx;
|
||||
}
|
||||
|
||||
.list-icon {
|
||||
@@ -354,7 +384,7 @@ const selectScript = async (id) => {
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
height: 94rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 28rpx;
|
||||
padding: 6rpx;
|
||||
display: grid;
|
||||
@@ -366,27 +396,209 @@ const selectScript = async (id) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
gap: 14rpx;
|
||||
color: rgba(216, 207, 238, 0.62);
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
color: #fff;
|
||||
border: 1rpx solid rgba(191, 91, 255, 0.85);
|
||||
background: rgba(115, 45, 255, 0.2);
|
||||
box-shadow: 0 0 30rpx rgba(167, 60, 255, 0.5);
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(208, 118, 255, 0.22), transparent 42%),
|
||||
rgba(115, 45, 255, 0.22);
|
||||
box-shadow: 0 0 30rpx rgba(167, 60, 255, 0.48), inset 0 0 22rpx rgba(190, 92, 255, 0.18);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
position: relative;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
color: #d875ff;
|
||||
font-size: 34rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gear-icon {
|
||||
position: relative;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.gear-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8rpx;
|
||||
top: 8rpx;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.spark,
|
||||
.panel-spark,
|
||||
.book-sparkle {
|
||||
position: absolute;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.spark::before,
|
||||
.spark::after,
|
||||
.panel-spark::before,
|
||||
.panel-spark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
border-radius: 999rpx;
|
||||
background: currentColor;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.spark::before,
|
||||
.panel-spark::before {
|
||||
width: 4rpx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spark::after,
|
||||
.panel-spark::after {
|
||||
width: 100%;
|
||||
height: 4rpx;
|
||||
}
|
||||
|
||||
.spark.main {
|
||||
left: 8rpx;
|
||||
top: 7rpx;
|
||||
width: 19rpx;
|
||||
height: 19rpx;
|
||||
}
|
||||
|
||||
.spark.dot {
|
||||
right: 3rpx;
|
||||
top: 2rpx;
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.panel-sparkles {
|
||||
position: relative;
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
flex-shrink: 0;
|
||||
color: #cf78ff;
|
||||
}
|
||||
|
||||
.panel-spark.large {
|
||||
left: 9rpx;
|
||||
top: 8rpx;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
}
|
||||
|
||||
.panel-spark.small {
|
||||
left: 1rpx;
|
||||
bottom: 3rpx;
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.bulb-icon {
|
||||
position: relative;
|
||||
width: 26rpx;
|
||||
height: 30rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bulb-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5rpx;
|
||||
top: 2rpx;
|
||||
width: 16rpx;
|
||||
height: 18rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 50% 50% 45% 45%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bulb-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9rpx;
|
||||
bottom: 2rpx;
|
||||
width: 10rpx;
|
||||
height: 8rpx;
|
||||
border-top: 3rpx solid currentColor;
|
||||
border-bottom: 3rpx solid currentColor;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
position: relative;
|
||||
width: 24rpx;
|
||||
height: 30rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mic-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7rpx;
|
||||
top: 1rpx;
|
||||
width: 10rpx;
|
||||
height: 19rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 999rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mic-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4rpx;
|
||||
bottom: 1rpx;
|
||||
width: 16rpx;
|
||||
height: 11rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 999rpx 999rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
position: relative;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.refresh-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-left-color: transparent;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-28deg);
|
||||
}
|
||||
|
||||
.refresh-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 3rpx;
|
||||
top: 3rpx;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8rpx solid currentColor;
|
||||
border-top: 5rpx solid transparent;
|
||||
border-bottom: 5rpx solid transparent;
|
||||
transform: rotate(25deg);
|
||||
}
|
||||
|
||||
.tab-title,
|
||||
@@ -395,45 +607,51 @@ const selectScript = async (id) => {
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 27rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tab-sub {
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
font-size: 19rpx;
|
||||
color: rgba(224, 214, 243, 0.52);
|
||||
}
|
||||
|
||||
.prompt-panel,
|
||||
.custom-panel {
|
||||
border-radius: 32rpx;
|
||||
padding: 30rpx;
|
||||
padding: 28rpx;
|
||||
border-color: rgba(173, 84, 255, 0.36);
|
||||
background:
|
||||
radial-gradient(circle at 16% 0%, rgba(143, 64, 255, 0.18), transparent 42%),
|
||||
rgba(12, 12, 42, 0.62);
|
||||
box-shadow: inset 0 0 34rpx rgba(132, 56, 255, 0.14), 0 0 26rpx rgba(140, 55, 255, 0.12);
|
||||
}
|
||||
|
||||
.panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: #c783ff;
|
||||
font-size: 29rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.prompt-box {
|
||||
width: 100%;
|
||||
height: 216rpx;
|
||||
height: 214rpx;
|
||||
box-sizing: border-box;
|
||||
margin-top: 28rpx;
|
||||
padding: 30rpx;
|
||||
margin-top: 26rpx;
|
||||
padding: 26rpx 28rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.26);
|
||||
background: rgba(13, 15, 43, 0.72);
|
||||
background: rgba(12, 14, 42, 0.86);
|
||||
color: #f7f1ff;
|
||||
font-size: 27rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -442,50 +660,28 @@ const selectScript = async (id) => {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 16rpx;
|
||||
align-items: center;
|
||||
margin-top: 18rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.counter {
|
||||
color: rgba(222, 211, 240, 0.62);
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.style-scroll {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.style-row {
|
||||
display: inline-flex;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.style-chip {
|
||||
display: inline-flex;
|
||||
height: 52rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 999rpx;
|
||||
align-items: center;
|
||||
color: rgba(224, 214, 243, 0.66);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.style-chip.active {
|
||||
color: #fff;
|
||||
background: rgba(152, 62, 255, 0.34);
|
||||
border-color: rgba(191, 91, 255, 0.82);
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
height: 104rpx;
|
||||
margin-top: 28rpx;
|
||||
margin-top: 24rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28rpx;
|
||||
overflow: visible;
|
||||
background:
|
||||
radial-gradient(circle at 92% 44%, rgba(244, 187, 255, 0.42), transparent 22%),
|
||||
linear-gradient(135deg, #b64cff 0%, #762fff 48%, #4a20ff 100%);
|
||||
box-shadow: 0 0 34rpx rgba(168, 85, 247, 0.58), inset 0 1rpx 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.generate-title,
|
||||
@@ -495,38 +691,72 @@ const selectScript = async (id) => {
|
||||
}
|
||||
|
||||
.generate-title {
|
||||
font-size: 30rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.generate-sub {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-size: 21rpx;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.planet-badge {
|
||||
position: relative;
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #e8b7ff, #8b37ff 62%, #4c1d95);
|
||||
box-shadow: 0 0 24rpx rgba(219, 143, 255, 0.7);
|
||||
background: radial-gradient(circle at 36% 28%, #f0c7ff, #a64cff 62%, #5a22d6);
|
||||
box-shadow: 0 0 30rpx rgba(219, 143, 255, 0.82);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.planet-badge::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12rpx;
|
||||
top: 27rpx;
|
||||
width: 90rpx;
|
||||
height: 20rpx;
|
||||
left: -20rpx;
|
||||
top: 34rpx;
|
||||
width: 124rpx;
|
||||
height: 24rpx;
|
||||
border: 4rpx solid rgba(231, 201, 255, 0.72);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.planet-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.planet-eye {
|
||||
position: absolute;
|
||||
top: 31rpx;
|
||||
width: 7rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #16062f;
|
||||
}
|
||||
|
||||
.planet-eye.left {
|
||||
left: 27rpx;
|
||||
}
|
||||
|
||||
.planet-eye.right {
|
||||
right: 27rpx;
|
||||
}
|
||||
|
||||
.planet-mouth {
|
||||
position: absolute;
|
||||
left: 34rpx;
|
||||
bottom: 23rpx;
|
||||
width: 14rpx;
|
||||
height: 9rpx;
|
||||
border-bottom: 3rpx solid #16062f;
|
||||
border-radius: 0 0 999rpx 999rpx;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
@@ -577,43 +807,44 @@ const selectScript = async (id) => {
|
||||
|
||||
.section-title {
|
||||
color: #c684ff;
|
||||
font-size: 31rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
color: #c99fff;
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.recommend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18rpx;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
min-height: 118rpx;
|
||||
min-height: 110rpx;
|
||||
border-radius: 22rpx;
|
||||
padding: 24rpx;
|
||||
padding: 22rpx;
|
||||
background: rgba(12, 12, 42, 0.54);
|
||||
}
|
||||
|
||||
.recommend-text {
|
||||
display: block;
|
||||
color: rgba(248, 244, 255, 0.9);
|
||||
font-size: 25rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.recommend-tag {
|
||||
display: inline-flex;
|
||||
margin-top: 18rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 7rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #d985ff;
|
||||
background: rgba(151, 66, 255, 0.18);
|
||||
font-size: 21rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.recent-section,
|
||||
@@ -625,8 +856,9 @@ const selectScript = async (id) => {
|
||||
|
||||
.script-card {
|
||||
border-radius: 24rpx;
|
||||
padding: 22rpx;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx;
|
||||
gap: 18rpx;
|
||||
background: rgba(12, 12, 42, 0.6);
|
||||
}
|
||||
|
||||
.script-card.selected {
|
||||
@@ -634,15 +866,15 @@ const selectScript = async (id) => {
|
||||
}
|
||||
|
||||
.script-cover {
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
width: 86rpx;
|
||||
height: 86rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 42rpx;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #7d35ff, #1bb7ff);
|
||||
}
|
||||
@@ -660,20 +892,20 @@ const selectScript = async (id) => {
|
||||
|
||||
.script-title {
|
||||
color: #fff;
|
||||
font-size: 27rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.script-date {
|
||||
margin-top: 8rpx;
|
||||
margin-top: 7rpx;
|
||||
color: rgba(218, 204, 243, 0.62);
|
||||
font-size: 22rpx;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.script-summary {
|
||||
margin-top: 8rpx;
|
||||
margin-top: 7rpx;
|
||||
color: rgba(218, 204, 243, 0.68);
|
||||
font-size: 23rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -682,7 +914,7 @@ const selectScript = async (id) => {
|
||||
|
||||
.empty-panel {
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx;
|
||||
padding: 26rpx;
|
||||
color: rgba(230, 218, 250, 0.66);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,24 +17,27 @@
|
||||
<MusicPlayer ref="musicPlayer" />
|
||||
|
||||
<view class="bottom-nav">
|
||||
<view class="nav-inner" :style="{ paddingBottom: safeAreaBottom + 'px' }">
|
||||
<view class="nav-inner">
|
||||
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
|
||||
<view class="planet-icon">
|
||||
<view class="tab-icon planet-ring-icon">
|
||||
<view class="planet-core"></view>
|
||||
<view class="planet-ring"></view>
|
||||
</view>
|
||||
<text>人生轨迹</text>
|
||||
</view>
|
||||
<view class="nav-item" :class="{ active: activeTab === 'script' }" @click="switchTab('script')">
|
||||
<view class="book-icon">
|
||||
<view></view>
|
||||
<view></view>
|
||||
<view class="tab-icon book-star-icon">
|
||||
<view class="book-page left"></view>
|
||||
<view class="book-page right"></view>
|
||||
<view class="book-sparkle"></view>
|
||||
</view>
|
||||
<text>爽文生成</text>
|
||||
</view>
|
||||
<view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
|
||||
<view class="smile-icon">
|
||||
<view class="eye left"></view>
|
||||
<view class="eye right"></view>
|
||||
<view class="tab-icon smile-face-icon">
|
||||
<view class="smile-eye left"></view>
|
||||
<view class="smile-eye right"></view>
|
||||
<view class="smile-mouth"></view>
|
||||
</view>
|
||||
<text>我的</text>
|
||||
</view>
|
||||
@@ -156,7 +159,7 @@ onUnmounted(() => {
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0 28rpx 156rpx;
|
||||
padding: 0 28rpx 132rpx;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
@@ -171,16 +174,21 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
height: 108rpx;
|
||||
height: 104rpx;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: center;
|
||||
border-radius: 34rpx;
|
||||
border: 1rpx solid rgba(153, 112, 255, 0.32);
|
||||
background: rgba(11, 9, 35, 0.84);
|
||||
box-shadow: inset 0 0 38rpx rgba(129, 65, 255, 0.12), 0 18rpx 70rpx rgba(0, 0, 0, 0.36);
|
||||
backdrop-filter: blur(26rpx);
|
||||
-webkit-backdrop-filter: blur(26rpx);
|
||||
border: 1rpx solid rgba(150, 95, 255, 0.26);
|
||||
background:
|
||||
radial-gradient(circle at 18% 14%, rgba(137, 78, 255, 0.18), transparent 36%),
|
||||
linear-gradient(180deg, rgba(20, 13, 52, 0.9), rgba(12, 7, 34, 0.94));
|
||||
box-shadow:
|
||||
inset 0 1rpx 0 rgba(255, 255, 255, 0.08),
|
||||
inset 0 0 34rpx rgba(130, 72, 255, 0.12),
|
||||
0 14rpx 42rpx rgba(0, 0, 0, 0.36);
|
||||
backdrop-filter: blur(28rpx);
|
||||
-webkit-backdrop-filter: blur(28rpx);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -189,93 +197,161 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
color: rgba(216, 208, 235, 0.58);
|
||||
font-size: 23rpx;
|
||||
font-weight: 600;
|
||||
transition: transform 0.22s ease, color 0.22s ease;
|
||||
gap: 5rpx;
|
||||
color: rgba(174, 165, 199, 0.72);
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
transition: transform 0.32s cubic-bezier(0.23, 1, 0.32, 1), color 0.32s ease, text-shadow 0.32s ease;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #b86cff;
|
||||
transform: translateY(-4rpx);
|
||||
text-shadow: 0 0 22rpx rgba(178, 91, 255, 0.8);
|
||||
color: #a855f7;
|
||||
transform: translateY(-6rpx);
|
||||
text-shadow: 0 0 24rpx rgba(168, 85, 247, 0.86);
|
||||
}
|
||||
|
||||
.planet-icon,
|
||||
.book-icon,
|
||||
.smile-icon {
|
||||
.tab-icon {
|
||||
position: relative;
|
||||
width: 46rpx;
|
||||
height: 42rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.planet-core {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 8rpx;
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
top: 11rpx;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 24rpx currentColor;
|
||||
box-shadow: 0 0 26rpx rgba(168, 85, 247, 0.74);
|
||||
}
|
||||
|
||||
.planet-icon::before {
|
||||
.planet-core::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2rpx;
|
||||
top: 14rpx;
|
||||
width: 42rpx;
|
||||
height: 14rpx;
|
||||
left: 9rpx;
|
||||
top: 7rpx;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.planet-ring {
|
||||
position: absolute;
|
||||
left: 1rpx;
|
||||
top: 16rpx;
|
||||
width: 48rpx;
|
||||
height: 16rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-22deg);
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
display: flex;
|
||||
gap: 4rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-icon view {
|
||||
width: 17rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 6rpx 3rpx 3rpx 6rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
box-shadow: 0 0 18rpx currentColor;
|
||||
}
|
||||
|
||||
.smile-icon {
|
||||
border-radius: 50%;
|
||||
border: 5rpx solid currentColor;
|
||||
box-sizing: border-box;
|
||||
transform: rotate(-22deg);
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.eye {
|
||||
.book-page {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
width: 6rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
top: 13rpx;
|
||||
width: 20rpx;
|
||||
height: 26rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
box-sizing: border-box;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.eye.left { left: 12rpx; }
|
||||
.eye.right { right: 12rpx; }
|
||||
.book-page.left {
|
||||
left: 5rpx;
|
||||
border-radius: 8rpx 3rpx 3rpx 8rpx;
|
||||
transform: skewY(5deg);
|
||||
}
|
||||
|
||||
.smile-icon::after {
|
||||
.book-page.right {
|
||||
right: 5rpx;
|
||||
border-radius: 3rpx 8rpx 8rpx 3rpx;
|
||||
transform: skewY(-5deg);
|
||||
}
|
||||
|
||||
.book-star-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
right: 12rpx;
|
||||
bottom: 10rpx;
|
||||
left: 23rpx;
|
||||
top: 15rpx;
|
||||
width: 3rpx;
|
||||
height: 23rpx;
|
||||
border-radius: 999rpx;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.book-sparkle {
|
||||
position: absolute;
|
||||
right: 4rpx;
|
||||
top: 4rpx;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.book-sparkle::before,
|
||||
.book-sparkle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
border-radius: 999rpx;
|
||||
background: currentColor;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.book-sparkle::before {
|
||||
width: 4rpx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.book-sparkle::after {
|
||||
width: 100%;
|
||||
height: 4rpx;
|
||||
}
|
||||
|
||||
.smile-face-icon {
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 18rpx rgba(174, 165, 199, 0.22);
|
||||
}
|
||||
|
||||
.nav-item.active .smile-face-icon {
|
||||
box-shadow: 0 0 24rpx rgba(168, 85, 247, 0.62);
|
||||
}
|
||||
|
||||
.smile-eye {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
width: 7rpx;
|
||||
height: 7rpx;
|
||||
border-radius: 50%;
|
||||
background: #100719;
|
||||
}
|
||||
|
||||
.smile-eye.left {
|
||||
left: 13rpx;
|
||||
}
|
||||
|
||||
.smile-eye.right {
|
||||
right: 13rpx;
|
||||
}
|
||||
|
||||
.smile-mouth {
|
||||
position: absolute;
|
||||
left: 16rpx;
|
||||
right: 16rpx;
|
||||
bottom: 14rpx;
|
||||
height: 8rpx;
|
||||
border-bottom: 4rpx solid currentColor;
|
||||
border-radius: 0 0 20rpx 20rpx;
|
||||
border-bottom: 4rpx solid #100719;
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,92 +5,133 @@
|
||||
|
||||
<view class="topbar">
|
||||
<text class="back" @click="goBack">‹</text>
|
||||
<text class="title">{{ isEdit ? '编辑资料' : '初始化档案' }} ✦</text>
|
||||
<text class="title">编辑资料 <text class="gold">✦</text></text>
|
||||
<text class="save" @click="saveProfile">保存</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
||||
<view class="hero-card kos-card">
|
||||
<view class="avatar">{{ avatarText }}</view>
|
||||
<view class="hero-card glass-card">
|
||||
<view class="avatar-wrap">
|
||||
<image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
|
||||
<view class="avatar-edit" @click="chooseAvatar">
|
||||
<view class="pen-icon"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-info">
|
||||
<text class="hero-name">{{ form.nickname || 'Zoey' }} ✦</text>
|
||||
<view class="hero-name-row">
|
||||
<text class="hero-name">{{ form.nickname || 'Zoey' }}</text>
|
||||
<text class="gold hero-star">✦</text>
|
||||
</view>
|
||||
<text class="hero-sub">正在成为更清晰的自己</text>
|
||||
<text class="quote">生活是自己的,选择也是。</text>
|
||||
<view class="hero-line"></view>
|
||||
<text class="hero-quote">生活是自己的,选择也是。”</text>
|
||||
</view>
|
||||
<view class="hero-planet">
|
||||
<view class="planet-core"></view>
|
||||
<view class="planet-ring"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel kos-card">
|
||||
<view class="panel-title">基础信息</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">昵称</text>
|
||||
<input class="line-input" v-model="form.nickname" placeholder="输入你的昵称" placeholder-class="placeholder" />
|
||||
<view class="panel glass-card">
|
||||
<view class="section-head">
|
||||
<view class="section-icon basic-icon"></view>
|
||||
<text class="section-title">基础信息</text>
|
||||
</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">性别</text>
|
||||
<view class="profile-row">
|
||||
<text class="row-label">昵称</text>
|
||||
<input class="row-input" v-model="form.nickname" placeholder="输入昵称" placeholder-class="placeholder" />
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
<view class="profile-row">
|
||||
<text class="row-label">性别</text>
|
||||
<view class="gender-row">
|
||||
<text v-for="item in genderOptions" :key="item" class="choice" :class="{ active: form.gender === item }" @click="form.gender = item">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">生日</text>
|
||||
<picker mode="date" :value="birthday" @change="onBirthday">
|
||||
<view class="line-value">{{ birthday || '选择生日' }} ›</view>
|
||||
<view class="profile-row">
|
||||
<text class="row-label">生日</text>
|
||||
<picker class="row-picker" mode="date" :value="birthday" @change="onBirthday">
|
||||
<view class="row-value">{{ birthdayDisplay }}</view>
|
||||
</picker>
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">所在城市</text>
|
||||
<input class="line-input" v-model="form.city" placeholder="填写城市" placeholder-class="placeholder" />
|
||||
<view class="profile-row">
|
||||
<text class="row-label">所在城市</text>
|
||||
<input class="row-input" v-model="form.city" placeholder="填写城市" placeholder-class="placeholder" />
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="dual-panel kos-card">
|
||||
<view class="select-block">
|
||||
<view class="block-title">星座</view>
|
||||
<view class="zodiac-grid">
|
||||
<view
|
||||
v-for="item in zodiacOptions"
|
||||
:key="item.name"
|
||||
class="zodiac-item"
|
||||
:class="{ active: form.zodiac === item.name }"
|
||||
@click="form.zodiac = item.name"
|
||||
>
|
||||
<text class="zodiac-symbol">{{ item.symbol }}</text>
|
||||
<text>{{ item.name }}</text>
|
||||
<view class="astro-panel">
|
||||
<view class="astro-col">
|
||||
<view class="astro-title-row">
|
||||
<view class="zodiac-head-icon">♋</view>
|
||||
<text class="astro-title">星座</text>
|
||||
<text class="astro-current">{{ form.zodiac || '巨蟹座' }}</text>
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
<text class="select-title">选择星座</text>
|
||||
<view class="zodiac-grid">
|
||||
<view
|
||||
v-for="item in zodiacOptions"
|
||||
:key="item.name"
|
||||
class="zodiac-item"
|
||||
:class="{ active: form.zodiac === item.name }"
|
||||
@click="form.zodiac = item.name"
|
||||
>
|
||||
<view class="zodiac-bubble">{{ item.symbol }}</view>
|
||||
<text>{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="astro-col mbti-col">
|
||||
<view class="astro-title-row">
|
||||
<view class="mbti-head-icon"></view>
|
||||
<text class="astro-title">MBTI</text>
|
||||
<text class="astro-current">{{ form.mbti || 'ENTJ' }}</text>
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
<text class="select-title">选择MBTI</text>
|
||||
<view class="mbti-grid">
|
||||
<text
|
||||
v-for="item in mbtiOptions"
|
||||
:key="item"
|
||||
class="mbti-chip"
|
||||
:class="{ active: form.mbti === item }"
|
||||
@click="form.mbti = item"
|
||||
>{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="select-block mbti-block">
|
||||
<view class="block-title">MBTI</view>
|
||||
<view class="mbti-grid">
|
||||
<text
|
||||
v-for="item in mbtiOptions"
|
||||
:key="item"
|
||||
class="mbti-chip"
|
||||
:class="{ active: form.mbti === item }"
|
||||
@click="form.mbti = item"
|
||||
>{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel glass-card">
|
||||
<view class="section-head">
|
||||
<view class="section-icon job-title-icon"></view>
|
||||
<text class="section-title">职业信息</text>
|
||||
</view>
|
||||
<view class="profile-row">
|
||||
<text class="row-label">职业</text>
|
||||
<input class="row-input" v-model="form.profession" placeholder="产品经理" placeholder-class="placeholder" />
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
<view class="profile-row">
|
||||
<text class="row-label">行业</text>
|
||||
<input class="row-input" v-model="form.industry" placeholder="互联网" placeholder-class="placeholder" />
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
<view class="profile-row">
|
||||
<text class="row-label">公司(可选)</text>
|
||||
<input class="row-input muted" v-model="form.company" placeholder="填写公司名称" placeholder-class="placeholder" />
|
||||
<text class="chevron">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel kos-card">
|
||||
<view class="panel-title">职业信息</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">职业</text>
|
||||
<input class="line-input" v-model="form.profession" placeholder="产品经理" placeholder-class="placeholder" />
|
||||
<view class="panel glass-card">
|
||||
<view class="section-head">
|
||||
<view class="section-icon smile-title-icon"></view>
|
||||
<text class="section-title">性格标签</text>
|
||||
<text class="section-hint">(最多选择5个)</text>
|
||||
</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">行业</text>
|
||||
<input class="line-input" v-model="form.industry" placeholder="互联网" placeholder-class="placeholder" />
|
||||
</view>
|
||||
<view class="line-field">
|
||||
<text class="field-label">公司(可选)</text>
|
||||
<input class="line-input" v-model="form.company" placeholder="填写公司名称" placeholder-class="placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel kos-card">
|
||||
<view class="panel-title">性格标签(最多选择5个)</view>
|
||||
<view class="tag-grid">
|
||||
<text
|
||||
v-for="tag in personalityTags"
|
||||
@@ -103,10 +144,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel kos-card">
|
||||
<view class="panel-head">
|
||||
<text class="panel-title">兴趣爱好(最多选择5个)</text>
|
||||
<text class="custom">+ 自定义兴趣</text>
|
||||
<view class="panel glass-card">
|
||||
<view class="section-head with-action">
|
||||
<view class="section-title-wrap">
|
||||
<view class="section-icon heart-title-icon"></view>
|
||||
<text class="section-title">兴趣爱好</text>
|
||||
<text class="section-hint">(最多选择5个)</text>
|
||||
</view>
|
||||
<text class="custom" @click="addCustomHobby">+ 自定义兴趣</text>
|
||||
</view>
|
||||
<view class="tag-grid">
|
||||
<text
|
||||
@@ -119,18 +164,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel kos-card">
|
||||
<view class="panel-title">个人简介</view>
|
||||
<textarea class="bio" v-model="form.future.ideal" maxlength="200" placeholder="热爱阅读和旅行,喜欢用文字和镜头记录生活。相信真诚和努力能让世界变得更美好。" placeholder-class="placeholder" />
|
||||
<view class="panel glass-card bio-panel">
|
||||
<view class="section-head">
|
||||
<view class="section-icon bio-title-icon"></view>
|
||||
<text class="section-title">个人简介</text>
|
||||
</view>
|
||||
<textarea
|
||||
class="bio"
|
||||
v-model="form.future.ideal"
|
||||
maxlength="200"
|
||||
:placeholder="bioPlaceholder"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
<text class="bio-count">{{ (form.future.ideal || '').length }}/200</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="bottom-bar">
|
||||
<button class="submit kos-primary" :loading="saving" :disabled="saving || !form.nickname.trim()" @click="saveProfile">
|
||||
{{ saving ? '正在保存' : '保存生命档案' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -143,12 +191,23 @@ const statusBarHeight = ref(20)
|
||||
const isEdit = ref(false)
|
||||
const saving = ref(false)
|
||||
const birthday = ref('')
|
||||
const avatarLocal = ref('')
|
||||
const bioPlaceholder = '热爱阅读和旅行,喜欢用文字和镜头记录生活。\n相信真诚和努力能让世界变得更美好。'
|
||||
|
||||
const genderOptions = ['女', '男', '不透露']
|
||||
const zodiacOptions = [
|
||||
{ name: '白羊座', symbol: '♈' }, { name: '金牛座', symbol: '♉' }, { name: '双子座', symbol: '♊' }, { name: '巨蟹座', symbol: '♋' },
|
||||
{ name: '狮子座', symbol: '♌' }, { name: '处女座', symbol: '♍' }, { name: '天秤座', symbol: '♎' }, { name: '天蝎座', symbol: '♏' },
|
||||
{ name: '射手座', symbol: '♐' }, { name: '摩羯座', symbol: '♑' }, { name: '水瓶座', symbol: '♒' }, { name: '双鱼座', symbol: '♓' }
|
||||
{ name: '白羊座', symbol: '♈' },
|
||||
{ name: '金牛座', symbol: '♉' },
|
||||
{ name: '双子座', symbol: '♊' },
|
||||
{ name: '巨蟹座', symbol: '♋' },
|
||||
{ name: '狮子座', symbol: '♌' },
|
||||
{ name: '处女座', symbol: '♍' },
|
||||
{ name: '天秤座', symbol: '♎' },
|
||||
{ name: '天蝎座', symbol: '♏' },
|
||||
{ name: '射手座', symbol: '♐' },
|
||||
{ name: '摩羯座', symbol: '♑' },
|
||||
{ name: '水瓶座', symbol: '♒' },
|
||||
{ name: '双鱼座', symbol: '♓' }
|
||||
]
|
||||
const mbtiOptions = ['INTJ', 'INTP', 'ENTJ', 'ENTP', 'INFJ', 'INFP', 'ENFJ', 'ENFP', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
|
||||
const personalityTags = ['理性', '感性', '乐观', '独立', '有创造力', '坚韧', '细腻', '好奇', '内敛', '冒险', '自由']
|
||||
@@ -171,27 +230,37 @@ const form = reactive({
|
||||
future: { vision: '', ideal: '' }
|
||||
})
|
||||
|
||||
const avatarText = computed(() => (form.nickname || 'Z').slice(0, 1))
|
||||
const avatarUrl = computed(() => {
|
||||
if (avatarLocal.value) return avatarLocal.value
|
||||
const nickname = form.nickname || 'Zoey'
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=b982ff`
|
||||
})
|
||||
|
||||
const birthdayDisplay = computed(() => {
|
||||
if (!birthday.value) return '1998年06月18日'
|
||||
const [year, month, day] = birthday.value.split('-')
|
||||
return `${year}年${month}月${day}日`
|
||||
})
|
||||
|
||||
const syncFromStore = () => {
|
||||
const source = store.userProfile || store.registrationData || {}
|
||||
Object.assign(form, {
|
||||
nickname: source.nickname || '',
|
||||
gender: source.gender || '',
|
||||
zodiac: source.zodiac || '',
|
||||
mbti: source.mbti || '',
|
||||
profession: source.profession || '',
|
||||
city: source.city || '',
|
||||
industry: source.industry || '',
|
||||
gender: source.gender || '女',
|
||||
zodiac: source.zodiac || '巨蟹座',
|
||||
mbti: source.mbti || 'ENTJ',
|
||||
profession: source.profession || '产品经理',
|
||||
city: source.city || '上海市',
|
||||
industry: source.industry || '互联网',
|
||||
company: source.company || '',
|
||||
personalityTags: Array.isArray(source.personalityTags) ? [...source.personalityTags] : [],
|
||||
hobbies: Array.isArray(source.hobbies) ? [...source.hobbies] : [],
|
||||
personalityTags: Array.isArray(source.personalityTags) && source.personalityTags.length ? [...source.personalityTags] : ['理性', '乐观', '独立', '有创造力', '坚韧'],
|
||||
hobbies: Array.isArray(source.hobbies) && source.hobbies.length ? [...source.hobbies] : ['阅读', '旅行', '音乐'],
|
||||
childhood: { date: source.childhood?.date || '', text: source.childhood?.text || '' },
|
||||
joy: { date: source.joy?.date || '', text: source.joy?.text || '' },
|
||||
low: { date: source.low?.date || '', text: source.low?.text || '' },
|
||||
future: { vision: source.future?.vision || '', ideal: source.future?.ideal || '' }
|
||||
future: { vision: source.future?.vision || '', ideal: source.future?.ideal || '热爱阅读和旅行,喜欢用文字和镜头记录生活。\n相信真诚和努力能让世界变得更美好。' }
|
||||
})
|
||||
birthday.value = source.birthday || ''
|
||||
birthday.value = source.birthday || '1998-06-18'
|
||||
}
|
||||
|
||||
const onBirthday = (event) => {
|
||||
@@ -211,6 +280,32 @@ const toggleList = (list, tag, max) => {
|
||||
list.push(tag)
|
||||
}
|
||||
|
||||
const chooseAvatar = () => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
avatarLocal.value = res.tempFilePaths?.[0] || ''
|
||||
uni.showToast({ title: '头像已更新到本地预览', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addCustomHobby = () => {
|
||||
uni.showModal({
|
||||
title: '自定义兴趣',
|
||||
editable: true,
|
||||
placeholderText: '输入兴趣名称',
|
||||
success: (res) => {
|
||||
const value = String(res.content || '').trim()
|
||||
if (!res.confirm || !value) return
|
||||
if (!hobbyOptions.includes(value)) hobbyOptions.push(value)
|
||||
toggleList(form.hobbies, value, 5)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!form.nickname.trim() || saving.value) return
|
||||
saving.value = true
|
||||
@@ -259,48 +354,52 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 86% 10%, rgba(124, 58, 237, 0.3), transparent 30%),
|
||||
radial-gradient(circle at 10% 32%, rgba(48, 112, 255, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #07091d 0%, #07031a 52%, #04020e 100%);
|
||||
radial-gradient(circle at 84% 3%, rgba(87, 122, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at 14% 26%, rgba(130, 71, 255, 0.16), transparent 30%),
|
||||
linear-gradient(180deg, #05081b 0%, #07031a 52%, #03020d 100%);
|
||||
}
|
||||
|
||||
.status-space,
|
||||
.topbar,
|
||||
.scroll,
|
||||
.bottom-bar {
|
||||
.scroll {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-space,
|
||||
.topbar,
|
||||
.bottom-bar {
|
||||
.topbar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 92rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 80rpx 1fr 80rpx;
|
||||
grid-template-columns: 90rpx 1fr 90rpx;
|
||||
align-items: center;
|
||||
padding: 0 28rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 66rpx;
|
||||
color: #fff;
|
||||
font-size: 68rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.save,
|
||||
.custom {
|
||||
color: #c06dff;
|
||||
font-size: 27rpx;
|
||||
.gold {
|
||||
color: #ffd58c;
|
||||
text-shadow: 0 0 20rpx rgba(255, 202, 125, 0.45);
|
||||
}
|
||||
|
||||
.save {
|
||||
color: #b94cff;
|
||||
font-size: 28rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -312,177 +411,443 @@ onMounted(() => {
|
||||
padding: 0 28rpx 28rpx;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel,
|
||||
.dual-panel {
|
||||
border-radius: 28rpx;
|
||||
margin-bottom: 22rpx;
|
||||
padding: 28rpx;
|
||||
.glass-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(124, 75, 255, 0.34);
|
||||
background:
|
||||
radial-gradient(circle at 92% 12%, rgba(104, 66, 255, 0.14), transparent 34%),
|
||||
rgba(10, 13, 43, 0.74);
|
||||
box-shadow: inset 0 0 34rpx rgba(123, 60, 255, 0.08), 0 14rpx 48rpx rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(24rpx);
|
||||
-webkit-backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
min-height: 190rpx;
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 22rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
min-height: 150rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 132rpx;
|
||||
height: 132rpx;
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 136rpx;
|
||||
height: 136rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 5rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 58rpx;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #b245ff, #2a7dff);
|
||||
box-shadow: 0 0 38rpx rgba(168, 85, 255, 0.52);
|
||||
background: linear-gradient(135deg, #fff, #9b54ff 46%, #4a67ff);
|
||||
box-shadow: 0 0 34rpx rgba(162, 91, 255, 0.52);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #27124a;
|
||||
}
|
||||
|
||||
.avatar-edit {
|
||||
position: absolute;
|
||||
right: -4rpx;
|
||||
bottom: -2rpx;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #8f4dff, #582cff);
|
||||
box-shadow: 0 0 18rpx rgba(158, 91, 255, 0.62);
|
||||
}
|
||||
|
||||
.pen-icon {
|
||||
width: 17rpx;
|
||||
height: 6rpx;
|
||||
margin: 18rpx auto;
|
||||
border-radius: 999rpx;
|
||||
background: #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-name,
|
||||
.hero-sub,
|
||||
.quote,
|
||||
.panel-title,
|
||||
.block-title {
|
||||
display: block;
|
||||
.hero-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
color: #fff;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-star {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin-top: 8rpx;
|
||||
color: rgba(239, 232, 255, 0.78);
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: rgba(239, 232, 255, 0.84);
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.quote {
|
||||
.hero-line {
|
||||
width: 280rpx;
|
||||
height: 1rpx;
|
||||
margin-top: 14rpx;
|
||||
color: #c06dff;
|
||||
font-size: 24rpx;
|
||||
background: rgba(180, 139, 255, 0.22);
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.block-title {
|
||||
margin-bottom: 20rpx;
|
||||
color: #eadcff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
.hero-quote {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: #b94cff;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
.hero-planet {
|
||||
position: absolute;
|
||||
right: 34rpx;
|
||||
top: 28rpx;
|
||||
width: 210rpx;
|
||||
height: 150rpx;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.planet-core {
|
||||
position: absolute;
|
||||
right: 30rpx;
|
||||
top: 24rpx;
|
||||
width: 86rpx;
|
||||
height: 86rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 34% 26%, #7d63ff, #4b1da8 62%, #19083b);
|
||||
box-shadow: 0 0 48rpx rgba(141, 78, 255, 0.58);
|
||||
}
|
||||
|
||||
.planet-ring {
|
||||
position: absolute;
|
||||
right: 4rpx;
|
||||
top: 58rpx;
|
||||
width: 158rpx;
|
||||
height: 34rpx;
|
||||
border: 5rpx solid rgba(161, 92, 255, 0.55);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 18rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.section-head,
|
||||
.section-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
min-height: 44rpx;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.section-head.with-action {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.line-field {
|
||||
min-height: 76rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 150rpx 1fr;
|
||||
align-items: center;
|
||||
border-top: 1rpx solid rgba(180, 139, 255, 0.14);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: rgba(221, 207, 246, 0.72);
|
||||
.section-title {
|
||||
color: rgba(239, 232, 255, 0.9);
|
||||
font-size: 25rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.line-input,
|
||||
.line-value {
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
.section-hint {
|
||||
color: rgba(222, 211, 240, 0.54);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
position: relative;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #a855ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.basic-icon::before {
|
||||
content: '♜';
|
||||
color: currentColor;
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.job-title-icon {
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 6rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.job-title-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7rpx;
|
||||
top: -8rpx;
|
||||
width: 10rpx;
|
||||
height: 7rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-bottom: 0;
|
||||
border-radius: 6rpx 6rpx 0 0;
|
||||
}
|
||||
|
||||
.smile-title-icon {
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid currentColor;
|
||||
}
|
||||
|
||||
.smile-title-icon::before,
|
||||
.smile-title-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
width: 4rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.smile-title-icon::before { left: 7rpx; }
|
||||
.smile-title-icon::after { right: 7rpx; }
|
||||
|
||||
.heart-title-icon::before {
|
||||
content: '♡';
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bio-title-icon {
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 5rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bio-title-icon::before,
|
||||
.bio-title-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6rpx;
|
||||
right: 6rpx;
|
||||
height: 3rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.bio-title-icon::before { top: 8rpx; }
|
||||
.bio-title-icon::after { top: 15rpx; }
|
||||
|
||||
.profile-row {
|
||||
min-height: 64rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 154rpx 1fr 24rpx;
|
||||
align-items: center;
|
||||
border-top: 1rpx solid rgba(180, 139, 255, 0.16);
|
||||
}
|
||||
|
||||
.section-head + .profile-row {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
color: rgba(205, 191, 238, 0.82);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.row-input,
|
||||
.row-value {
|
||||
min-width: 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 24rpx;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.row-picker {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(217, 205, 238, 0.42);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: rgba(218, 204, 243, 0.7);
|
||||
font-size: 44rpx;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gender-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.choice,
|
||||
.mbti-chip,
|
||||
.tag-choice {
|
||||
height: 52rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(224, 214, 243, 0.72);
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
font-size: 23rpx;
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
font-size: 22rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.choice.active,
|
||||
.mbti-chip.active,
|
||||
.tag-choice.active {
|
||||
color: #fff;
|
||||
border-color: rgba(202, 97, 255, 0.9);
|
||||
background: rgba(149, 55, 255, 0.38);
|
||||
box-shadow: 0 0 24rpx rgba(168, 67, 255, 0.38);
|
||||
border-color: rgba(206, 82, 255, 0.95);
|
||||
background: linear-gradient(180deg, rgba(169, 61, 255, 0.62), rgba(107, 41, 206, 0.5));
|
||||
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.52), inset 0 1rpx 0 rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.dual-panel {
|
||||
.astro-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24rpx;
|
||||
margin-top: 8rpx;
|
||||
border-top: 1rpx solid rgba(180, 139, 255, 0.16);
|
||||
}
|
||||
|
||||
.mbti-block {
|
||||
border-left: 1rpx solid rgba(180, 139, 255, 0.14);
|
||||
padding-left: 24rpx;
|
||||
.astro-col {
|
||||
min-width: 0;
|
||||
padding: 18rpx 18rpx 0 0;
|
||||
}
|
||||
|
||||
.mbti-col {
|
||||
border-left: 1rpx solid rgba(180, 139, 255, 0.18);
|
||||
padding-left: 18rpx;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.astro-title-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34rpx 74rpx 1fr 18rpx;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.zodiac-head-icon,
|
||||
.mbti-head-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #a855ff;
|
||||
}
|
||||
|
||||
.zodiac-head-icon {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mbti-head-icon {
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 4rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.astro-title {
|
||||
color: rgba(222, 211, 240, 0.76);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.astro-current {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 23rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.select-title {
|
||||
display: block;
|
||||
margin-top: 22rpx;
|
||||
color: rgba(222, 211, 240, 0.72);
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.zodiac-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12rpx;
|
||||
gap: 14rpx 8rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.zodiac-item {
|
||||
min-height: 88rpx;
|
||||
border-radius: 18rpx;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
color: rgba(226, 217, 246, 0.84);
|
||||
font-size: 19rpx;
|
||||
}
|
||||
|
||||
.zodiac-bubble {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(224, 214, 243, 0.76);
|
||||
}
|
||||
|
||||
.zodiac-item.active {
|
||||
color: #fff;
|
||||
background: rgba(149, 55, 255, 0.26);
|
||||
box-shadow: inset 0 0 18rpx rgba(168, 67, 255, 0.28);
|
||||
}
|
||||
|
||||
.zodiac-symbol {
|
||||
color: #a855ff;
|
||||
font-size: 32rpx;
|
||||
font-size: 28rpx;
|
||||
background: rgba(124, 58, 237, 0.28);
|
||||
border: 1rpx solid rgba(173, 84, 255, 0.36);
|
||||
}
|
||||
|
||||
.zodiac-item.active .zodiac-bubble {
|
||||
color: #fff;
|
||||
border-color: rgba(215, 128, 255, 0.95);
|
||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.8), rgba(98, 47, 190, 0.66));
|
||||
box-shadow: 0 0 22rpx rgba(190, 92, 255, 0.72);
|
||||
}
|
||||
|
||||
.mbti-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.mbti-chip {
|
||||
height: 42rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.tag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14rpx;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14rpx 24rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.tag-choice {
|
||||
height: 40rpx;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.tag-choice.dashed {
|
||||
@@ -490,41 +855,38 @@ onMounted(() => {
|
||||
color: #c06dff;
|
||||
}
|
||||
|
||||
.custom {
|
||||
color: #c06dff;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.bio-panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bio {
|
||||
width: 100%;
|
||||
height: 140rpx;
|
||||
height: 116rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 22rpx;
|
||||
border-radius: 22rpx;
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.24);
|
||||
background: rgba(12, 15, 46, 0.72);
|
||||
margin-top: 16rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border-radius: 18rpx;
|
||||
border: 1rpx solid rgba(151, 111, 255, 0.22);
|
||||
background: rgba(10, 12, 40, 0.66);
|
||||
color: #fff;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.55;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bio-count {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
margin-top: 8rpx;
|
||||
text-align: right;
|
||||
color: rgba(224, 214, 243, 0.56);
|
||||
font-size: 22rpx;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
height: 116rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 14rpx 28rpx 22rpx;
|
||||
background: rgba(5, 6, 21, 0.72);
|
||||
backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
.placeholder {
|
||||
color: rgba(214, 204, 235, 0.42);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,11 +37,30 @@ export const deleteEvent = async (id) => {
|
||||
return response
|
||||
}
|
||||
|
||||
export const assistEventWriting = async (eventData = {}) => {
|
||||
return post('/lifeEvent/ai-assist', eventData)
|
||||
}
|
||||
|
||||
export const chatAboutEvent = async (eventData = {}) => {
|
||||
return post('/lifeEvent/chat-placeholder', eventData)
|
||||
}
|
||||
|
||||
export const shareEvent = async (eventData = {}) => {
|
||||
return post('/lifeEvent/share-placeholder', eventData)
|
||||
}
|
||||
|
||||
export const favoriteEvent = async ({ id, favorite }) => {
|
||||
return post('/lifeEvent/favorite-placeholder', { id, favorite })
|
||||
}
|
||||
|
||||
const transformToBackendFormat = (frontendData) => {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
time,
|
||||
timeMode = 'date',
|
||||
eventDateText,
|
||||
endTime,
|
||||
content,
|
||||
aiFeedback,
|
||||
eventType = 'daily_log',
|
||||
@@ -54,6 +73,9 @@ const transformToBackendFormat = (frontendData) => {
|
||||
id,
|
||||
title,
|
||||
eventDate: time,
|
||||
timeMode,
|
||||
eventDateText: eventDateText || time,
|
||||
eventEndDate: timeMode === 'range' ? endTime : null,
|
||||
content,
|
||||
aiReply: aiFeedback,
|
||||
eventType,
|
||||
@@ -71,6 +93,9 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
userId,
|
||||
title,
|
||||
eventDate,
|
||||
timeMode,
|
||||
eventDateText,
|
||||
eventEndDate,
|
||||
content,
|
||||
aiReply,
|
||||
eventType,
|
||||
@@ -85,6 +110,9 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
userId,
|
||||
title: title || '',
|
||||
time: eventDate ? eventDate.split('T')[0] : '',
|
||||
timeMode: timeMode || 'date',
|
||||
eventDateText: eventDateText || (eventDate ? eventDate.split('T')[0] : ''),
|
||||
endTime: eventEndDate ? eventEndDate.split('T')[0] : '',
|
||||
content: content || '',
|
||||
aiFeedback: aiReply || '',
|
||||
eventType: eventType || 'daily_log',
|
||||
@@ -107,6 +135,10 @@ export default {
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
assistEventWriting,
|
||||
chatAboutEvent,
|
||||
shareEvent,
|
||||
favoriteEvent,
|
||||
transformToFrontendFormat,
|
||||
transformListToFrontend
|
||||
}
|
||||
|
||||
@@ -49,11 +49,12 @@ const transformToBackendFormat = (frontendData) => {
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
steps: inputSteps,
|
||||
status = 'active',
|
||||
progress = 0
|
||||
} = frontendData
|
||||
|
||||
let steps = []
|
||||
let steps = Array.isArray(inputSteps) ? inputSteps : []
|
||||
if (content) {
|
||||
const stepMatches = content.match(/(\d+)\.\s*([^::]+)[::]\s*([^\n]+)/g)
|
||||
if (stepMatches) {
|
||||
@@ -123,7 +124,13 @@ export const transformToFrontendFormat = (backendData) => {
|
||||
title: title || '实现路径',
|
||||
description: description || '',
|
||||
content,
|
||||
steps: steps || [],
|
||||
steps: Array.isArray(steps)
|
||||
? steps.map((step, index) => ({
|
||||
...step,
|
||||
task: step.task || step.phase || `阶段${index + 1}`,
|
||||
desc: step.desc || step.content || step.action || ''
|
||||
}))
|
||||
: [],
|
||||
status: status || 'active',
|
||||
progress: progress || 0,
|
||||
createTime
|
||||
|
||||
@@ -151,6 +151,62 @@ const createEvent = async (eventData) => {
|
||||
}
|
||||
}
|
||||
|
||||
const updateEvent = async (eventData) => {
|
||||
try {
|
||||
await lifeEventService.updateEvent(eventData)
|
||||
await fetchEvents()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEvent = async (id) => {
|
||||
try {
|
||||
await lifeEventService.deleteEvent(id)
|
||||
await fetchEvents()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const assistEventWriting = async (eventData) => {
|
||||
try {
|
||||
const res = await lifeEventService.assistEventWriting(eventData)
|
||||
return { success: true, data: res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const chatAboutEvent = async (eventData) => {
|
||||
try {
|
||||
const res = await lifeEventService.chatAboutEvent(eventData)
|
||||
return { success: true, data: res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const shareEvent = async (eventData) => {
|
||||
try {
|
||||
const res = await lifeEventService.shareEvent(eventData)
|
||||
return { success: true, data: res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const favoriteEvent = async ({ id, favorite }) => {
|
||||
try {
|
||||
const res = await lifeEventService.favoriteEvent({ id, favorite })
|
||||
return { success: true, data: res.data }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const getEventById = (id) => {
|
||||
return state.events.find(event => String(event.id) === String(id)) || null
|
||||
}
|
||||
@@ -350,6 +406,12 @@ export const useAppStore = () => {
|
||||
setCurrentStep,
|
||||
fetchEvents,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
assistEventWriting,
|
||||
chatAboutEvent,
|
||||
shareEvent,
|
||||
favoriteEvent,
|
||||
getEventById,
|
||||
fetchScripts,
|
||||
createScript,
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE t_life_event
|
||||
ADD COLUMN time_mode VARCHAR(20) DEFAULT 'date' COMMENT '时间模式: date-具体日期, month-年月, season-季节, range-时间范围' AFTER event_date,
|
||||
ADD COLUMN event_date_text VARCHAR(50) COMMENT '原始时间文本' AFTER time_mode,
|
||||
ADD COLUMN event_end_date DATETIME COMMENT '结束日期,仅时间范围使用' AFTER event_date_text;
|
||||
|
||||
UPDATE t_life_event
|
||||
SET time_mode = COALESCE(time_mode, 'date'),
|
||||
event_date_text = COALESCE(event_date_text, DATE_FORMAT(event_date, '%Y-%m-%d'))
|
||||
WHERE is_deleted = 0;
|
||||
@@ -1246,6 +1246,9 @@ CREATE TABLE t_life_event (
|
||||
-- 事件信息
|
||||
event_type VARCHAR(50) DEFAULT 'daily_log' COMMENT '事件类型: daily_log-日常记录, milestone-里程碑',
|
||||
event_date DATETIME COMMENT '事件日期',
|
||||
time_mode VARCHAR(20) DEFAULT 'date' COMMENT '时间模式: date-具体日期, month-年月, season-季节, range-时间范围',
|
||||
event_date_text VARCHAR(50) COMMENT '原始时间文本',
|
||||
event_end_date DATETIME COMMENT '结束日期,仅时间范围使用',
|
||||
title VARCHAR(200) COMMENT '事件标题',
|
||||
content TEXT COMMENT '事件内容',
|
||||
ai_reply TEXT COMMENT 'AI疗愈回复',
|
||||
|
||||
@@ -51,3 +51,21 @@ export function deleteAdmin(id: string) {
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
|
||||
// 修改管理员自己的密码(当前登录管理员)
|
||||
export function changeMyPassword(data: { oldPassword: string; newPassword: string }) {
|
||||
return request<ApiResponse<void>>({
|
||||
url: '/admin/auth/changePassword',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 重置指定管理员的密码(超管操作)
|
||||
export function resetAdminPassword(data: { id: string; newPassword: string }) {
|
||||
return request<ApiResponse<void>>({
|
||||
url: '/admin/changePassword',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">个人信息</el-dropdown-item>
|
||||
<el-dropdown-item command="changePassword">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -73,6 +74,25 @@
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="passwordDialogVisible" title="修改密码" width="450px">
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="80px">
|
||||
<el-form-item label="原密码" prop="oldPassword">
|
||||
<el-input v-model="passwordForm.oldPassword" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="passwordForm.confirmPassword" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handlePasswordSubmit" :loading="passwordSubmitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -81,6 +101,8 @@ import { useRoute } from 'vue-router'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { menuConfig } from '@/config/menu'
|
||||
import { changeMyPassword } from '@/api/admin'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
// const router = useRouter()
|
||||
@@ -91,6 +113,53 @@ const adminInfo = computed(() => adminStore.adminInfo)
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const passwordDialogVisible = ref(false)
|
||||
const passwordSubmitting = ref(false)
|
||||
const passwordFormRef = ref<FormInstance>()
|
||||
const passwordForm = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== passwordForm.value.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const passwordRules: FormRules = {
|
||||
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }],
|
||||
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
await passwordFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
passwordSubmitting.value = true
|
||||
try {
|
||||
await changeMyPassword({
|
||||
oldPassword: passwordForm.value.oldPassword,
|
||||
newPassword: passwordForm.value.newPassword
|
||||
})
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
passwordDialogVisible.value = false
|
||||
passwordForm.value = { oldPassword: '', newPassword: '', confirmPassword: '' }
|
||||
adminStore.logout()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '修改密码失败')
|
||||
} finally {
|
||||
passwordSubmitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const menuRoutes = computed(() => {
|
||||
return menuConfig.filter(item => !item.hidden)
|
||||
})
|
||||
@@ -104,6 +173,8 @@ const handleCommand = (command: string) => {
|
||||
adminStore.logout()
|
||||
} else if (command === 'profile') {
|
||||
// TODO: 跳转到个人信息页面
|
||||
} else if (command === 'changePassword') {
|
||||
passwordDialogVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="warning" link @click="handleChangePassword(row)">修改密码</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -128,13 +129,32 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="changePasswordVisible" title="修改密码" width="450px">
|
||||
<el-form ref="changePwFormRef" :model="changePwForm" :rules="changePwRules" label-width="80px">
|
||||
<el-form-item label="账号">
|
||||
<el-input :model-value="changePwForm.account" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input v-model="changePwForm.newPassword" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="changePwForm.confirmPassword" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="changePasswordVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleResetPasswordSubmit" :loading="changePwSubmitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getAdminPage, createAdmin, updateAdmin, deleteAdmin } from '@/api/admin'
|
||||
import { getAdminPage, createAdmin, updateAdmin, deleteAdmin, resetAdminPassword } from '@/api/admin'
|
||||
import type { Admin, AdminPageRequest } from '@/types/admin'
|
||||
import { validateAccount, validatePassword, validateEmail, validatePhone } from '@/utils/validate'
|
||||
|
||||
@@ -181,6 +201,31 @@ const formRules: FormRules = {
|
||||
|
||||
const dialogTitle = ref('新增管理员')
|
||||
|
||||
const changePasswordVisible = ref(false)
|
||||
const changePwSubmitting = ref(false)
|
||||
const changePwFormRef = ref<FormInstance>()
|
||||
const changePwForm = reactive({
|
||||
id: '',
|
||||
account: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validateConfirmPw = (_rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== changePwForm.newPassword) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const changePwRules: FormRules = {
|
||||
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }],
|
||||
confirmPassword: [{ required: true, validator: validateConfirmPw, trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -245,6 +290,34 @@ const handleDelete = (row: Admin) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleChangePassword = (row: Admin) => {
|
||||
changePwForm.id = row.id
|
||||
changePwForm.account = row.account
|
||||
changePwForm.newPassword = ''
|
||||
changePwForm.confirmPassword = ''
|
||||
changePasswordVisible.value = true
|
||||
}
|
||||
|
||||
const handleResetPasswordSubmit = async () => {
|
||||
if (!changePwFormRef.value) return
|
||||
await changePwFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
changePwSubmitting.value = true
|
||||
try {
|
||||
await resetAdminPassword({
|
||||
id: changePwForm.id,
|
||||
newPassword: changePwForm.newPassword
|
||||
})
|
||||
ElMessage.success('密码重置成功')
|
||||
changePasswordVisible.value = false
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '重置密码失败')
|
||||
} finally {
|
||||
changePwSubmitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<template>
|
||||
<div class="ai-config-list">
|
||||
<h2 class="page-title">AI配置管理</h2>
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">AI 配置管理</h2>
|
||||
<p class="page-desc">管理 AI 服务提供商的 API 配置、参数、费用及使用场景</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd" size="large">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="search-card">
|
||||
<!-- 搜索过滤 -->
|
||||
<el-card class="search-card" shadow="never">
|
||||
<el-form :model="searchForm" :inline="true" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="配置名称/键值/描述" clearable style="width: 200px" />
|
||||
<el-input v-model="searchForm.keyword" placeholder="配置名称/键值/描述" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置类型">
|
||||
<el-select v-model="searchForm.configType" placeholder="请选择配置类型" clearable style="width: 150px">
|
||||
<el-select v-model="searchForm.configType" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in CONFIG_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
@@ -18,7 +30,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务提供商">
|
||||
<el-select v-model="searchForm.provider" placeholder="请选择服务提供商" clearable style="width: 150px">
|
||||
<el-select v-model="searchForm.provider" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in PROVIDER_OPTIONS"
|
||||
:key="item.value"
|
||||
@@ -28,7 +40,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="使用场景">
|
||||
<el-select v-model="searchForm.usageScenario" placeholder="请选择使用场景" clearable style="width: 150px">
|
||||
<el-select v-model="searchForm.usageScenario" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in USAGE_SCENARIO_OPTIONS"
|
||||
:key="item.value"
|
||||
@@ -38,146 +50,153 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.isEnabled" placeholder="请选择状态" clearable style="width: 120px">
|
||||
<el-select v-model="searchForm.isEnabled" placeholder="全部" clearable>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="环境">
|
||||
<el-select v-model="searchForm.environment" placeholder="请选择环境" clearable style="width: 130px">
|
||||
<el-option
|
||||
v-for="item in ENVIRONMENT_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-form-item class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>AI配置列表</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="success" @click="handleRefreshStats">刷新统计</el-button>
|
||||
<el-button type="primary" @click="handleAdd">新增配置</el-button>
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Files /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-label">总配置数</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card stat-enabled">
|
||||
<div class="stat-icon">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.enabled }}</div>
|
||||
<div class="stat-label">已启用</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card stat-disabled">
|
||||
<div class="stat-icon">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.disabled }}</div>
|
||||
<div class="stat-label">已禁用</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card stat-default">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Star /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.default }}</div>
|
||||
<div class="stat-label">默认配置</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-row">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-statistic title="总配置数" :value="stats.total" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="已启用" :value="stats.enabled" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="已禁用" :value="stats.disabled" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="默认配置" :value="stats.default" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe>
|
||||
<el-table-column prop="configName" label="配置名称" width="150" />
|
||||
<el-table-column prop="configKey" label="配置键值" width="150" />
|
||||
<el-table-column prop="configType" label="配置类型" width="100">
|
||||
<!-- 数据表格 -->
|
||||
<el-card class="table-card" shadow="never">
|
||||
<el-table :data="tableData" v-loading="loading" stripe class="data-table">
|
||||
<el-table-column prop="configName" label="配置名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="configKey" label="配置键值" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="configType" label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getConfigTypeTagType(row.configType)">
|
||||
<el-tag :type="getConfigTypeTagType(row.configType)" effect="plain" size="small">
|
||||
{{ getConfigTypeLabel(row.configType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="provider" label="服务提供商" width="120">
|
||||
<el-table-column prop="provider" label="服务商" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getProviderTagType(row.provider)">
|
||||
<el-tag :type="getProviderTagType(row.provider)" effect="plain" size="small">
|
||||
{{ getProviderLabel(row.provider) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="usageScenario" label="使用场景" width="120">
|
||||
<el-table-column prop="usageScenario" label="使用场景" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info">
|
||||
<el-tag type="info" effect="plain" size="small">
|
||||
{{ getUsageScenarioLabel(row.usageScenario) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="environment" label="环境" width="100">
|
||||
<el-table-column prop="environment" label="环境" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getEnvironmentTagType(row.environment)">
|
||||
<el-tag :type="getEnvironmentTagType(row.environment)" effect="plain" size="small">
|
||||
{{ getEnvironmentLabel(row.environment) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="80" />
|
||||
<el-table-column prop="isEnabled" label="状态" width="80">
|
||||
<el-table-column prop="isEnabled" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'">
|
||||
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain" size="small">
|
||||
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isDefault" label="默认" width="80">
|
||||
<el-table-column prop="isDefault" label="默认" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDefault === 1" type="warning">默认</el-tag>
|
||||
<span v-else>-</span>
|
||||
<el-tag v-if="row.isDefault === 1" type="warning" effect="dark" size="small">默认</el-tag>
|
||||
<span v-else class="dash">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="150" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="info" link @click="handleView(row)">查看</el-button>
|
||||
<el-button type="success" link @click="handleTest(row)">测试</el-button>
|
||||
<el-button
|
||||
:type="row.isEnabled === 1 ? 'warning' : 'success'"
|
||||
link
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.isEnabled === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.isDefault !== 1"
|
||||
type="warning"
|
||||
link
|
||||
@click="handleSetDefault(row)"
|
||||
>
|
||||
设为默认
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="info"
|
||||
link
|
||||
@click="handleUnsetDefault(row)"
|
||||
>
|
||||
取消默认
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
<div class="action-group">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleView(row)">查看</el-button>
|
||||
<el-button type="success" link size="small" @click="handleTest(row)">测试</el-button>
|
||||
<el-divider direction="vertical" class="action-divider" />
|
||||
<el-button
|
||||
:type="row.isEnabled === 1 ? 'warning' : 'success'"
|
||||
link
|
||||
size="small"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.isEnabled === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="row.isDefault === 1 ? 'info' : 'warning'"
|
||||
link
|
||||
size="small"
|
||||
@click="row.isDefault === 1 ? handleUnsetDefault(row) : handleSetDefault(row)"
|
||||
>
|
||||
{{ row.isDefault === 1 ? '取消默认' : '设为默认' }}
|
||||
</el-button>
|
||||
<el-divider direction="vertical" class="action-divider" />
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchData"
|
||||
@current-change="fetchData"
|
||||
class="pagination"
|
||||
/>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchData"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
@@ -692,7 +711,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { InfoFilled, Plus, Files, CircleCheck, CircleClose, Star } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getAiConfigPage,
|
||||
createAiConfig,
|
||||
@@ -936,11 +955,6 @@ const handleReset = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新统计
|
||||
const handleRefreshStats = () => {
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
@@ -1578,53 +1592,223 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-config-list {
|
||||
.page-title {
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
color: var(--ls-text);
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
// 页面头部
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
}
|
||||
.header-left {
|
||||
.page-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--ls-text, #e2e8f0);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--ls-text-secondary, rgba(226, 232, 240, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--ls-radius-lg);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
justify-content: flex-end;
|
||||
.header-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索卡片
|
||||
.search-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 8px;
|
||||
margin-right: 16px;
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-select__wrapper) {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
margin-left: auto;
|
||||
|
||||
.el-button {
|
||||
min-width: 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片行
|
||||
.stats-row {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--ls-card-bg, rgba(30, 41, 59, 0.6));
|
||||
border: 1px solid var(--ls-border, rgba(255, 255, 255, 0.06));
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
margin-right: 16px;
|
||||
|
||||
:deep(.el-icon) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--ls-text-secondary, rgba(226, 232, 240, 0.55));
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// 各统计卡片配色
|
||||
&.stat-total {
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
}
|
||||
.stat-value {
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
&.stat-enabled {
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
.stat-value {
|
||||
color: #4ade80;
|
||||
}
|
||||
}
|
||||
|
||||
&.stat-disabled {
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
.stat-value {
|
||||
color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
&.stat-default {
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
.stat-value {
|
||||
color: #fbbf24;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据表格
|
||||
.table-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
:deep(.el-table__header) th {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--ls-text-secondary, rgba(226, 232, 240, 0.7));
|
||||
background: var(--ls-table-header-bg, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
:deep(.el-table__row) {
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--ls-table-hover-bg, rgba(99, 102, 241, 0.06)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dash {
|
||||
color: var(--ls-text-secondary, rgba(226, 232, 240, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮分组
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
|
||||
.action-divider {
|
||||
margin: 0 2px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--ls-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
// 表单提示
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
@@ -1632,6 +1816,7 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试容器
|
||||
.test-container {
|
||||
.test-section {
|
||||
h4 {
|
||||
|
||||