Commit a1251cdc by 王立鹏

Merge branch 'feature/21402-【YAYA文化岛】优化点整理' into 'master'

Feature/21402 【yaya文化岛】优化点整理

See merge request !23
parents c31d6dcb dcda6cf1
---
name: migrate-oxfmt
description: Guide for migrating a project from Prettier or Biome to Oxfmt. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's formatter from Prettier or Biome to Oxfmt.
---
This skill guides you through migrating a JavaScript/TypeScript project from Prettier or Biome to [Oxfmt](https://oxc.rs/docs/guide/usage/formatter).
## Overview
Oxfmt is a high-performance, Prettier-compatible code formatter. Most Prettier options are supported directly.
An automated migration tool is built into oxfmt, supporting both Prettier and Biome as migration sources.
## Step 1: Run Automated Migration
### From Prettier
```bash
npx oxfmt@latest --migrate prettier
```
This will:
- Find and read your Prettier config (any format Prettier supports)
- Create `.oxfmtrc.json` with migrated options
- Migrate `.prettierignore` patterns to `ignorePatterns`
- Migrate `prettier-plugin-tailwindcss` options to `sortTailwindcss`
- Detect `prettier-plugin-packagejson` and enable `sortPackageJson`
### From Biome
```bash
npx oxfmt@latest --migrate biome
```
This will:
- Find and read `biome.json` or `biome.jsonc`
- Create `.oxfmtrc.json` with migrated options
- Migrate negated patterns from `files.includes` to `ignorePatterns`
- Map Biome's two-level config (`formatter.*` and `javascript.formatter.*`) to oxfmt options
Biome option mapping:
| Biome | oxfmt |
| ----------------------------------------------------------- | --------------------------------- |
| `formatter.indentStyle` (`"tab"`/`"space"`) | `useTabs` (`true`/`false`) |
| `formatter.indentWidth` | `tabWidth` |
| `formatter.lineWidth` | `printWidth` |
| `javascript.formatter.quoteStyle` | `singleQuote` |
| `javascript.formatter.jsxQuoteStyle` | `jsxSingleQuote` |
| `javascript.formatter.quoteProperties` (`"asNeeded"`) | `quoteProps` (`"as-needed"`) |
| `javascript.formatter.trailingCommas` | `trailingComma` |
| `javascript.formatter.semicolons` (`"always"`/`"asNeeded"`) | `semi` (`true`/`false`) |
| `javascript.formatter.arrowParentheses` (`"asNeeded"`) | `arrowParens` (`"avoid"`) |
| `formatter.bracketSameLine` | `bracketSameLine` |
| `formatter.bracketSpacing` | `bracketSpacing` |
| `formatter.attributePosition` (`"multiline"`) | `singleAttributePerLine` (`true`) |
Notes (both sources):
- Fails if `.oxfmtrc.json` already exists. Delete it first if you want to re-run.
- If no source config is found, creates a blank `.oxfmtrc.json` instead.
- `overrides` cannot be auto-migrated for either source and must be converted manually.
## Step 2: Review Generated Config
After migration, review the generated `.oxfmtrc.json` for these key differences:
### printWidth
Prettier and Biome default is 80, oxfmt default is 100. The migration tool sets `printWidth: 80` if not specified in your source config. Decide whether to keep 80 or adopt 100.
### Unsupported Options (Prettier only)
These Prettier options are skipped during migration:
| Option | Status |
| ------------------------------ | ------------------------------------------------ |
| `endOfLine: "auto"` | Not supported. Use `"lf"` or `"crlf"` explicitly |
| `experimentalTernaries` | Not supported in JS/TS files yet |
| `experimentalOperatorPosition` | Not supported in JS/TS files yet |
### sortPackageJson (Prettier only)
Enabled by default in oxfmt, but the migration tool disables it unless `prettier-plugin-packagejson` was detected. Review whether you want this enabled.
Note: Oxfmt's sorting algorithm differs from `prettier-plugin-packagejson`.
### embeddedLanguageFormatting (Prettier only)
Embedded language formatting (e.g., CSS-in-JS) generally works, but some formatting may differ from Prettier.
### overrides
The `overrides` field cannot be auto-migrated from either Prettier or Biome. Convert manually:
```json
{
"overrides": [
{
"files": ["*.md"],
"options": { "tabWidth": 4 }
}
]
}
```
### Nested Config
Oxfmt does not support nested configuration files (e.g., a separate `.oxfmtrc.json` in a subdirectory). If your project used per-directory Prettier or Biome configs, consolidate them using `overrides` with file glob patterns, or run oxfmt separately per directory with different working directories.
### Prettier-Compatible Options
These options transfer directly with the same behavior:
`tabWidth`, `useTabs`, `semi`, `singleQuote`, `jsxSingleQuote`, `quoteProps`, `trailingComma`, `arrowParens`, `bracketSpacing`, `bracketSameLine`, `endOfLine`, `proseWrap`, `htmlWhitespaceSensitivity`, `singleAttributePerLine`, `vueIndentScriptAndStyle`
## Step 3: Configure Oxfmt Extensions
Oxfmt offers features not available in Prettier:
### sortImports
Sort import statements, inspired by `eslint-plugin-perfectionist/sort-imports` (disabled by default):
```json
{
"sortImports": {
"partitionByNewline": true,
"newlinesBetween": false
}
}
```
### sortTailwindcss
Replaces `prettier-plugin-tailwindcss`. Auto-migrated with renamed options:
| Prettier (top-level) | oxfmt (`sortTailwindcss.*`) |
| ---------------------------- | --------------------------- |
| `tailwindConfig` | `config` |
| `tailwindStylesheet` | `stylesheet` |
| `tailwindFunctions` | `functions` |
| `tailwindAttributes` | `attributes` |
| `tailwindPreserveWhitespace` | `preserveWhitespace` |
| `tailwindPreserveDuplicates` | `preserveDuplicates` |
### Other Extensions
| Option | Default | Description |
| -------------------- | ------- | ---------------------------------------------------------------------------- |
| `insertFinalNewline` | `true` | Whether to add a final newline at end of file |
| `sortPackageJson` | `true` | Sort `package.json` keys. Set `{ "sortScripts": true }` to also sort scripts |
## Step 4: Update CI and Scripts
Replace formatter commands with oxfmt:
```bash
# Before (Prettier)
npx prettier --write .
npx prettier --check .
# Before (Biome)
npx biome format --write .
npx biome check .
# After
npx oxfmt@latest
npx oxfmt@latest --check
```
### Common CLI Options
| Prettier / Biome | oxfmt |
| ----------------------------------------------- | -------------------------------------------- |
| `prettier --write .` / `biome format --write .` | `oxfmt` (default: cwd, `--write` mode) |
| `prettier --check .` / `biome check .` | `oxfmt --check` |
| `prettier --list-different .` | `oxfmt --list-different` |
| `prettier --config path` | `oxfmt --config path` |
| `prettier --ignore-path .prettierignore` | `oxfmt --ignore-path .prettierignore` |
| `cat file \| prettier --stdin-filepath=file.ts` | `cat file \| oxfmt --stdin-filepath=file.ts` |
### File Type Coverage
- JS/TS: Formatted natively by oxfmt
- TOML: Formatted natively (via taplo)
- CSS, HTML, YAML, Markdown, GraphQL, etc.: Delegated to Prettier internally (when using `npx oxfmt`)
## Tips
- EditorConfig: Oxfmt reads `.editorconfig` automatically for `useTabs`, `tabWidth`, `endOfLine`, `insertFinalNewline`, and `printWidth`. Options in `.oxfmtrc.json` take precedence.
- CI: Use `npx oxfmt@latest --check` to enforce formatting in CI.
- LSP: Run `oxfmt --lsp` for editor integration via Language Server Protocol.
- Schema support: Add `"$schema": "./node_modules/oxfmt/configuration_schema.json"` to `.oxfmtrc.json` for editor autocompletion.
- Init: Run `npx oxfmt@latest --init` to create a default `.oxfmtrc.json` without migration.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/formatter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html)
- [Unsupported Features](https://oxc.rs/docs/guide/usage/formatter/unsupported-features.html)
---
name: migrate-oxlint
description: Guide for migrating a project from ESLint to Oxlint. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's linter from ESLint to Oxlint.
---
This skill guides you through migrating a JavaScript/TypeScript project from ESLint to [Oxlint](https://oxc.rs/docs/guide/usage/linter/).
## Overview
Oxlint is a high-performance linter that implements many popular ESLint rules natively in Rust. It can be used alongside ESLint or as a full replacement.
An official migration tool is available, and will be used by this skill: [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate)
## Step 1: Run Automated Migration
Run the migration tool in the project root:
```bash
npx @oxlint/migrate
```
This reads your ESLint flat config (`eslint.config.js` for example) and generates a `.oxlintrc.json` file from it. It will find your ESLint config file automatically in most cases.
See options below for more info.
### Key Options
| Option | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--type-aware` | Include type-aware rules from `@typescript-eslint` (will require the `oxlint-tsgolint` package to be installed after migrating) |
| `--with-nursery` | Include experimental rules still under development, may not be fully stable or consistent with ESLint equivalents |
| `--js-plugins [bool]` | Enable/disable ESLint plugin migration via `jsPlugins` (default: enabled) |
| `--details` | List rules that could not be migrated |
| `--replace-eslint-comments` | Convert all `// eslint-disable` comments to `// oxlint-disable` |
| `--output-file <file>` | Specify a different output path (default: `.oxlintrc.json`) |
If your ESLint config is not at the default location, pass the path explicitly:
```bash
npx @oxlint/migrate ./path/to/eslint.config.js
```
## Step 2: Review Generated Config
After migration, review the generated `.oxlintrc.json`.
### Plugin Mapping
The migration tool automatically maps ESLint plugins to oxlint's built-in equivalents. The following table is for reference when reviewing the generated config:
| ESLint Plugin | Oxlint Plugin Name |
| --------------------------------------------------- | ------------------ |
| `@typescript-eslint/eslint-plugin` | `typescript` |
| `eslint-plugin-react` / `eslint-plugin-react-hooks` | `react` |
| `eslint-plugin-import` / `eslint-plugin-import-x` | `import` |
| `eslint-plugin-unicorn` | `unicorn` |
| `eslint-plugin-jsx-a11y` | `jsx-a11y` |
| `eslint-plugin-react-perf` | `react-perf` |
| `eslint-plugin-promise` | `promise` |
| `eslint-plugin-jest` | `jest` |
| `@vitest/eslint-plugin` | `vitest` |
| `eslint-plugin-jsdoc` | `jsdoc` |
| `eslint-plugin-next` | `nextjs` |
| `eslint-plugin-node` | `node` |
| `eslint-plugin-vue` | `vue` |
Default plugins (enabled when `plugins` field is omitted): `unicorn`, `typescript`, `oxc`.
Setting the `plugins` array explicitly overrides these defaults.
ESLint core rules are usable in oxlint without needing to configure a plugin in the config file.
### Rule Categories
Oxlint groups rules into categories for bulk configuration, though only `correctness` is enabled by default:
```json
{
"categories": {
"correctness": "error",
"suspicious": "warn"
}
}
```
Available categories: `correctness` (default: enabled), `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`.
Individual rule settings in `rules` override category settings.
`@oxlint/migrate` will turn `correctness` off to avoid enabling additional rules that weren't enabled by your ESLint config. You can choose to enable additional categories after migration if desired.
### Check Unmigrated Rules
Run with `--details` to see which ESLint rules could not be migrated:
```bash
npx @oxlint/migrate --details
```
Review the output and decide whether to keep ESLint for those rules or not. Some rules may be mentioned in the output from `--details` as having equivalents in oxlint that were not automatically mapped by the migration tool. In those cases, consider enabling the equivalent oxlint rule manually after migration.
## Step 3: Install Oxlint
Install the core oxlint package (use `yarn install`, `pnpm install`, `vp install`, `bun install`, etc. depending on your package manager):
```bash
npm install -D oxlint
```
If you want to add the `oxlint-tsgolint` package, if you intend to use type-aware rules that require TypeScript type information:
```bash
npm install -D oxlint-tsgolint
```
No other packages besides the above are needed by default, though you will need to keep/install any additional ESLint plugins that were migrated into `jsPlugins`. Do not add `@oxlint/migrate` to the package.json, it is meant for one-off usage.
## Step 4: Handle Unsupported Features
Some features require manual attention:
- Local plugins (relative path imports): Must be migrated manually to `jsPlugins`
- `eslint-plugin-prettier`: Supported, but very slow. It is recommended to use [oxfmt](https://oxc.rs/docs/guide/usage/formatter) instead, or switch to `prettier --check` as a separate step alongside oxlint.
- `settings` in override configs: Oxlint does not support `settings` inside `overrides` blocks.
- ESLint v9+ plugins: Not all work with oxlint's JS Plugins API, but the majority will.
### Local Plugins
If you have any custom ESLint rules in the project repo itself, you can migrate them manually after running the migration tool by adding them to the `jsPlugins` field in `.oxlintrc.json`:
```json
{
"jsPlugins": ["./path/to/my-plugin.js"],
"rules": {
"local-plugin/rule-name": "error"
}
}
```
### External ESLint Plugins
For ESLint plugins without a built-in oxlint equivalent, use the `jsPlugins` field to load them:
```json
{
"jsPlugins": ["eslint-plugin-custom"],
"rules": {
"custom/my-rule": "warn"
}
}
```
## Step 5: Update CI and Scripts
Replace ESLint commands with oxlint. Path arguments are optional; oxlint defaults to the current working directory.
```bash
# Before
npx eslint src/
npx eslint --fix src/
# After
npx oxlint src/
npx oxlint --fix src/
```
### Common CLI Options
| ESLint | oxlint equivalent |
| ------------------------- | ---------------------------------------------- |
| `eslint .` | `oxlint` (default: lints the cwd) |
| `eslint src/` | `oxlint src/` |
| `eslint --fix` | `oxlint --fix` |
| `eslint --max-warnings 0` | `oxlint --deny-warnings` or `--max-warnings 0` |
| `eslint --format json` | `oxlint --format json` |
Additional oxlint options:
- `--tsconfig <path>`: Specify tsconfig.json path, likely unnecessary unless you have a non-standard name for `tsconfig.json`.
## Tips
- You can run alongside ESLint if necessary: Oxlint is designed to complement ESLint during migration, but with JS Plugins many projects can switch over fully without losing many rules.
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` when running @oxlint/migrate to convert them to `// oxlint-disable` equivalents if desired.
- List available rules: Run `npx oxlint --rules` to see all supported rules, or refer to the [rule documentation](https://oxc.rs/docs/guide/usage/linter/rules.html).
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion if the migration tool didn't do it automatically.
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
- Ignore files: `.eslintignore` is supported by oxlint if you have it, but it's recommended to move any ignore patterns into the `ignorePatterns` field in `.oxlintrc.json` for consistency and simplicity. All files and paths ignored via a `.gitignore` file will be ignored by oxlint by default as well.
- If you ran the migration tool multiple times, remove the `.oxlintrc.json.bak` backup file created by the migration tool once you've finished migrating.
- If you are not using any JS Plugins and have replaced your ESLint configuration, you can remove all ESLint packages from your project dependencies.
- Ensure your editor is configured to use oxlint instead of ESLint for linting and error reporting. You may want to install the Oxc extension for your preferred editor. See https://oxc.rs/docs/guide/usage/linter/editors.html for more details.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/linter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html)
- [Complete Oxlint rule list and docs](https://oxc.rs/docs/guide/usage/linter/rules.html)
# Generation Info
- **Source:** `sources/vue`
- **Git SHA:** `01abf2d03815d9d0ff0b06362a68d5d9542c9e48`
- **Generated:** 2026-01-31
---
name: vue
description: Vue 3 Composition API, script setup macros, reactivity system, and built-in components. Use when writing Vue SFCs, defineProps/defineEmits/defineModel, watchers, or using Transition/Teleport/Suspense/KeepAlive.
metadata:
author: Anthony Fu
version: "2026.1.31"
source: Generated from https://github.com/vuejs/docs, scripts at https://github.com/antfu/skills
---
# Vue
> Based on Vue 3.5. Always use Composition API with `<script setup lang="ts">`.
## Preferences
- Prefer TypeScript over JavaScript
- Prefer `<script setup lang="ts">` over `<script>`
- For performance, prefer `shallowRef` over `ref` if deep reactivity is not needed
- Always use Composition API over Options API
- Discourage using Reactive Props Destructure
## Core
| Topic | Description | Reference |
|-------|-------------|-----------|
| Script Setup & Macros | `<script setup>`, defineProps, defineEmits, defineModel, defineExpose, defineOptions, defineSlots, generics | [script-setup-macros](references/script-setup-macros.md) |
| Reactivity & Lifecycle | ref, shallowRef, computed, watch, watchEffect, effectScope, lifecycle hooks, composables | [core-new-apis](references/core-new-apis.md) |
## Features
| Topic | Description | Reference |
|-------|-------------|-----------|
| Built-in Components & Directives | Transition, Teleport, Suspense, KeepAlive, v-memo, custom directives | [advanced-patterns](references/advanced-patterns.md) |
## Quick Reference
### Component Template
```vue
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps<{
title: string
count?: number
}>()
const emit = defineEmits<{
update: [value: string]
}>()
const model = defineModel<string>()
const doubled = computed(() => (props.count ?? 0) * 2)
watch(() => props.title, (newVal) => {
console.log('Title changed:', newVal)
})
onMounted(() => {
console.log('Component mounted')
})
</script>
<template>
<div>{{ title }} - {{ doubled }}</div>
</template>
```
### Key Imports
```ts
// Reactivity
import { ref, shallowRef, computed, reactive, readonly, toRef, toRefs, toValue } from 'vue'
// Watchers
import { watch, watchEffect, watchPostEffect, onWatcherCleanup } from 'vue'
// Lifecycle
import { onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onBeforeUnmount } from 'vue'
// Utilities
import { nextTick, defineComponent, defineAsyncComponent } from 'vue'
```
---
name: advanced-patterns
description: Vue 3 built-in components (Transition, Teleport, Suspense, KeepAlive) and advanced directives
---
# Built-in Components & Directives
## Transition
Animate enter/leave of a single element or component.
```vue
<template>
<Transition name="fade">
<div v-if="show">Content</div>
</Transition>
</template>
<style>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>
```
### CSS Classes
| Class | When |
|-------|------|
| `{name}-enter-from` | Start state for enter |
| `{name}-enter-active` | Active state for enter (add transitions here) |
| `{name}-enter-to` | End state for enter |
| `{name}-leave-from` | Start state for leave |
| `{name}-leave-active` | Active state for leave |
| `{name}-leave-to` | End state for leave |
### Transition Modes
```vue
<!-- Wait for leave to complete before enter -->
<Transition name="fade" mode="out-in">
<component :is="currentView" />
</Transition>
```
### JavaScript Hooks
```vue
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
:css="false"
>
<div v-if="show">Content</div>
</Transition>
<script setup lang="ts">
function onEnter(el: Element, done: () => void) {
// Animate with JS library
gsap.to(el, { opacity: 1, onComplete: done })
}
</script>
```
### Appear on Initial Render
```vue
<Transition appear name="fade">
<div>Shows with animation on mount</div>
</Transition>
```
## TransitionGroup
Animate list items. Each child must have a unique `key`.
```vue
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.text }}
</li>
</TransitionGroup>
</template>
<style>
.list-enter-active, .list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from, .list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* Move animation for reordering */
.list-move {
transition: transform 0.3s ease;
}
</style>
```
## Teleport
Render content to a different DOM location.
```vue
<template>
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal">
Modal content rendered at body
</div>
</Teleport>
</template>
```
### Props
```vue
<!-- CSS selector -->
<Teleport to="#modal-container">
<!-- DOM element -->
<Teleport :to="targetElement">
<!-- Disable teleport conditionally -->
<Teleport to="body" :disabled="isMobile">
<!-- Defer until target exists (Vue 3.5+) -->
<Teleport defer to="#late-rendered-target">
```
## Suspense
Handle async dependencies with loading states. **Experimental feature.**
```vue
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
```
### Async Dependencies
Suspense waits for:
- Components with `async setup()`
- Components using top-level `await` in `<script setup>`
- Async components created with `defineAsyncComponent`
```vue
<!-- AsyncComponent.vue -->
<script setup lang="ts">
const data = await fetch('/api/data').then(r => r.json())
</script>
```
### Events
```vue
<Suspense
@pending="onPending"
@resolve="onResolve"
@fallback="onFallback"
>
...
</Suspense>
```
## KeepAlive
Cache component instances when toggled.
```vue
<template>
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
</template>
```
### Include/Exclude
```vue
<!-- By name (string or regex) -->
<KeepAlive include="ComponentA,ComponentB">
<KeepAlive :include="/^Tab/">
<KeepAlive :include="['TabA', 'TabB']">
<!-- Exclude -->
<KeepAlive exclude="ModalComponent">
<!-- Max cached instances -->
<KeepAlive :max="10">
```
### Lifecycle Hooks
```ts
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// Called when component is inserted from cache
fetchLatestData()
})
onDeactivated(() => {
// Called when component is removed to cache
pauseTimers()
})
```
## v-memo
Skip re-renders when dependencies unchanged. Use for performance optimization.
```vue
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
<!-- Only re-renders when item.selected changes -->
<ExpensiveComponent :item="item" />
</div>
</template>
```
Equivalent to `v-once` when empty:
```vue
<div v-memo="[]">Never updates</div>
```
## v-once
Render once, skip all future updates.
```vue
<span v-once>Static: {{ neverChanges }}</span>
```
## Custom Directives
Create reusable DOM manipulations.
```ts
// Directive definition
const vFocus: Directive<HTMLElement> = {
mounted: (el) => el.focus()
}
// Full hooks
const vColor: Directive<HTMLElement, string> = {
created(el, binding, vnode, prevVnode) {},
beforeMount(el, binding) {},
mounted(el, binding) {
el.style.color = binding.value
},
beforeUpdate(el, binding) {},
updated(el, binding) {
el.style.color = binding.value
},
beforeUnmount(el, binding) {},
unmounted(el, binding) {}
}
```
### Directive Arguments & Modifiers
```vue
<div v-color:background.bold="'red'">
<script setup lang="ts">
const vColor: Directive<HTMLElement, string> = {
mounted(el, binding) {
// binding.arg = 'background'
// binding.modifiers = { bold: true }
// binding.value = 'red'
el.style[binding.arg || 'color'] = binding.value
if (binding.modifiers.bold) {
el.style.fontWeight = 'bold'
}
}
}
</script>
```
### Global Registration
```ts
// main.ts
app.directive('focus', {
mounted: (el) => el.focus()
})
```
<!--
Source references:
- https://vuejs.org/api/built-in-components.html
- https://vuejs.org/guide/built-ins/transition.html
- https://vuejs.org/guide/built-ins/teleport.html
- https://vuejs.org/guide/built-ins/suspense.html
- https://vuejs.org/guide/built-ins/keep-alive.html
- https://vuejs.org/api/built-in-directives.html
- https://vuejs.org/guide/reusability/custom-directives.html
-->
---
name: core-new-apis
description: Vue 3 reactivity system, lifecycle hooks, and composable patterns
---
# Reactivity, Lifecycle & Composables
## Reactivity
### ref vs shallowRef
```ts
import { ref, shallowRef } from 'vue'
// ref - deep reactivity (tracks nested changes)
const user = ref({ name: 'John', profile: { age: 30 } })
user.value.profile.age = 31 // Triggers reactivity
// shallowRef - only .value assignment triggers reactivity (better performance)
const data = shallowRef({ items: [] })
data.value.items.push('new') // Does NOT trigger reactivity
data.value = { items: ['new'] } // Triggers reactivity
```
**Prefer `shallowRef`** for large data structures or when deep reactivity is unnecessary.
### computed
```ts
import { ref, computed } from 'vue'
const count = ref(0)
// Read-only computed
const doubled = computed(() => count.value * 2)
// Writable computed
const plusOne = computed({
get: () => count.value + 1,
set: (val) => { count.value = val - 1 }
})
```
### reactive & readonly
```ts
import { reactive, readonly } from 'vue'
const state = reactive({ count: 0, nested: { value: 1 } })
state.count++ // Reactive
const readonlyState = readonly(state)
readonlyState.count++ // Warning, mutation blocked
```
Note: `reactive()` loses reactivity on destructuring. Use `ref()` or `toRefs()`.
## Watchers
### watch
```ts
import { ref, watch } from 'vue'
const count = ref(0)
// Watch single ref
watch(count, (newVal, oldVal) => {
console.log(`Changed from ${oldVal} to ${newVal}`)
})
// Watch getter
watch(
() => props.id,
(id) => fetchData(id),
{ immediate: true }
)
// Watch multiple sources
watch([firstName, lastName], ([first, last]) => {
fullName.value = `${first} ${last}`
})
// Deep watch with depth limit (Vue 3.5+)
watch(state, callback, { deep: 2 })
// Once (Vue 3.4+)
watch(source, callback, { once: true })
```
### watchEffect
Runs immediately and auto-tracks dependencies.
```ts
import { ref, watchEffect, onWatcherCleanup } from 'vue'
const id = ref(1)
watchEffect(async () => {
const controller = new AbortController()
// Cleanup on re-run or unmount (Vue 3.5+)
onWatcherCleanup(() => controller.abort())
const res = await fetch(`/api/${id.value}`, { signal: controller.signal })
data.value = await res.json()
})
// Pause/resume (Vue 3.5+)
const { pause, resume, stop } = watchEffect(() => {})
pause()
resume()
stop()
```
### Flush Timing
```ts
// 'pre' (default) - before component update
// 'post' - after component update (access updated DOM)
// 'sync' - immediate, use with caution
watch(source, callback, { flush: 'post' })
watchPostEffect(() => {}) // Alias for flush: 'post'
```
## Lifecycle Hooks
```ts
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated, // KeepAlive
onDeactivated, // KeepAlive
onServerPrefetch // SSR only
} from 'vue'
onMounted(() => {
console.log('DOM is ready')
})
onUnmounted(() => {
// Cleanup timers, listeners, etc.
})
// Error boundary
onErrorCaptured((err, instance, info) => {
console.error(err)
return false // Stop propagation
})
```
## Effect Scope
Group reactive effects for batch disposal.
```ts
import { effectScope, onScopeDispose } from 'vue'
const scope = effectScope()
scope.run(() => {
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, () => console.log(count.value))
// Cleanup when scope stops
onScopeDispose(() => {
console.log('Scope disposed')
})
})
// Dispose all effects
scope.stop()
```
## Composables
Composables are functions that encapsulate stateful logic using Composition API.
### Naming Convention
- Start with `use`: `useMouse`, `useFetch`, `useCounter`
### Pattern
```ts
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
const update = (e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
```
### Accept Reactive Input
Use `toValue()` (Vue 3.3+) to normalize refs, getters, or plain values.
```ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch(url: MaybeRefOrGetter<string>) {
const data = ref(null)
const error = ref(null)
watchEffect(async () => {
data.value = null
error.value = null
try {
const res = await fetch(toValue(url))
data.value = await res.json()
} catch (e) {
error.value = e
}
})
return { data, error }
}
// Usage - all work:
useFetch('/api/users')
useFetch(urlRef)
useFetch(() => `/api/users/${props.id}`)
```
### Return Refs (Not Reactive)
Always return plain object with refs for destructuring compatibility.
```ts
// Good - preserves reactivity when destructured
return { x, y }
// Bad - loses reactivity when destructured
return reactive({ x, y })
```
<!--
Source references:
- https://vuejs.org/api/reactivity-core.html
- https://vuejs.org/api/reactivity-advanced.html
- https://vuejs.org/api/composition-api-lifecycle.html
- https://vuejs.org/guide/reusability/composables.html
-->
---
name: script-setup-macros
description: Vue 3 script setup syntax and compiler macros for defining props, emits, models, and more
---
# Script Setup & Macros
`<script setup>` is the recommended syntax for Vue SFCs with Composition API. It provides better runtime performance and IDE type inference.
## Basic Syntax
```vue
<script setup lang="ts">
// Top-level bindings are exposed to template
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
<button @click="increment">{{ count }}</button>
<MyComponent />
</template>
```
## defineProps
Declare component props with full TypeScript support.
```ts
// Type-based declaration (recommended)
const props = defineProps<{
title: string
count?: number
items: string[]
}>()
// With defaults (Vue 3.5+)
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
// With defaults (Vue 3.4 and below)
const props = withDefaults(defineProps<{
title: string
items?: string[]
}>(), {
items: () => [] // Use factory for arrays/objects
})
```
## defineEmits
Declare emitted events with typed payloads.
```ts
// Named tuple syntax (recommended)
const emit = defineEmits<{
update: [value: string]
change: [id: number, name: string]
close: []
}>()
emit('update', 'new value')
emit('change', 1, 'name')
emit('close')
```
## defineModel
Two-way binding prop consumed via `v-model`. Available in Vue 3.4+.
```ts
// Basic usage - creates "modelValue" prop
const model = defineModel<string>()
model.value = 'hello' // Emits "update:modelValue"
// Named model - consumed via v-model:name
const count = defineModel<number>('count', { default: 0 })
// With modifiers
const [value, modifiers] = defineModel<string>()
if (modifiers.trim) {
// Handle trim modifier
}
// With transformers
const [value, modifiers] = defineModel({
get(val) { return val?.toLowerCase() },
set(val) { return modifiers.trim ? val?.trim() : val }
})
```
Parent usage:
```vue
<Child v-model="name" />
<Child v-model:count="total" />
<Child v-model.trim="text" />
```
## defineExpose
Explicitly expose properties to parent via template refs. Components are closed by default.
```ts
import { ref } from 'vue'
const count = ref(0)
const reset = () => { count.value = 0 }
defineExpose({
count,
reset
})
```
Parent access:
```ts
const childRef = ref<{ count: number; reset: () => void }>()
childRef.value?.reset()
```
## defineOptions
Declare component options without a separate `<script>` block. Available in Vue 3.3+.
```ts
defineOptions({
inheritAttrs: false,
name: 'CustomName'
})
```
## defineSlots
Provide type hints for slot props. Available in Vue 3.3+.
```ts
const slots = defineSlots<{
default(props: { item: string; index: number }): any
header(props: { title: string }): any
}>()
```
## Generic Components
Declare generic type parameters using the `generic` attribute.
```vue
<script setup lang="ts" generic="T extends string | number">
defineProps<{
items: T[]
selected: T
}>()
</script>
```
Multiple generics with constraints:
```vue
<script setup lang="ts" generic="T, U extends Record<string, T>">
import type { Item } from './types'
defineProps<{
data: U
key: keyof U
}>()
</script>
```
## Local Custom Directives
Use `vNameOfDirective` naming convention.
```ts
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
}
// Or import and rename
import { myDirective as vMyDirective } from './directives'
```
```vue
<template>
<input v-focus />
</template>
```
## Top-level await
Use `await` directly in `<script setup>`. The component becomes async and must be used with `<Suspense>`.
```vue
<script setup lang="ts">
const data = await fetch('/api/data').then(r => r.json())
</script>
```
<!--
Source references:
- https://vuejs.org/api/sfc-script-setup.html
-->
---
name: migrate-oxfmt
description: Guide for migrating a project from Prettier or Biome to Oxfmt. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's formatter from Prettier or Biome to Oxfmt.
---
This skill guides you through migrating a JavaScript/TypeScript project from Prettier or Biome to [Oxfmt](https://oxc.rs/docs/guide/usage/formatter).
## Overview
Oxfmt is a high-performance, Prettier-compatible code formatter. Most Prettier options are supported directly.
An automated migration tool is built into oxfmt, supporting both Prettier and Biome as migration sources.
## Step 1: Run Automated Migration
### From Prettier
```bash
npx oxfmt@latest --migrate prettier
```
This will:
- Find and read your Prettier config (any format Prettier supports)
- Create `.oxfmtrc.json` with migrated options
- Migrate `.prettierignore` patterns to `ignorePatterns`
- Migrate `prettier-plugin-tailwindcss` options to `sortTailwindcss`
- Detect `prettier-plugin-packagejson` and enable `sortPackageJson`
### From Biome
```bash
npx oxfmt@latest --migrate biome
```
This will:
- Find and read `biome.json` or `biome.jsonc`
- Create `.oxfmtrc.json` with migrated options
- Migrate negated patterns from `files.includes` to `ignorePatterns`
- Map Biome's two-level config (`formatter.*` and `javascript.formatter.*`) to oxfmt options
Biome option mapping:
| Biome | oxfmt |
| ----------------------------------------------------------- | --------------------------------- |
| `formatter.indentStyle` (`"tab"`/`"space"`) | `useTabs` (`true`/`false`) |
| `formatter.indentWidth` | `tabWidth` |
| `formatter.lineWidth` | `printWidth` |
| `javascript.formatter.quoteStyle` | `singleQuote` |
| `javascript.formatter.jsxQuoteStyle` | `jsxSingleQuote` |
| `javascript.formatter.quoteProperties` (`"asNeeded"`) | `quoteProps` (`"as-needed"`) |
| `javascript.formatter.trailingCommas` | `trailingComma` |
| `javascript.formatter.semicolons` (`"always"`/`"asNeeded"`) | `semi` (`true`/`false`) |
| `javascript.formatter.arrowParentheses` (`"asNeeded"`) | `arrowParens` (`"avoid"`) |
| `formatter.bracketSameLine` | `bracketSameLine` |
| `formatter.bracketSpacing` | `bracketSpacing` |
| `formatter.attributePosition` (`"multiline"`) | `singleAttributePerLine` (`true`) |
Notes (both sources):
- Fails if `.oxfmtrc.json` already exists. Delete it first if you want to re-run.
- If no source config is found, creates a blank `.oxfmtrc.json` instead.
- `overrides` cannot be auto-migrated for either source and must be converted manually.
## Step 2: Review Generated Config
After migration, review the generated `.oxfmtrc.json` for these key differences:
### printWidth
Prettier and Biome default is 80, oxfmt default is 100. The migration tool sets `printWidth: 80` if not specified in your source config. Decide whether to keep 80 or adopt 100.
### Unsupported Options (Prettier only)
These Prettier options are skipped during migration:
| Option | Status |
| ------------------------------ | ------------------------------------------------ |
| `endOfLine: "auto"` | Not supported. Use `"lf"` or `"crlf"` explicitly |
| `experimentalTernaries` | Not supported in JS/TS files yet |
| `experimentalOperatorPosition` | Not supported in JS/TS files yet |
### sortPackageJson (Prettier only)
Enabled by default in oxfmt, but the migration tool disables it unless `prettier-plugin-packagejson` was detected. Review whether you want this enabled.
Note: Oxfmt's sorting algorithm differs from `prettier-plugin-packagejson`.
### embeddedLanguageFormatting (Prettier only)
Embedded language formatting (e.g., CSS-in-JS) generally works, but some formatting may differ from Prettier.
### overrides
The `overrides` field cannot be auto-migrated from either Prettier or Biome. Convert manually:
```json
{
"overrides": [
{
"files": ["*.md"],
"options": { "tabWidth": 4 }
}
]
}
```
### Nested Config
Oxfmt does not support nested configuration files (e.g., a separate `.oxfmtrc.json` in a subdirectory). If your project used per-directory Prettier or Biome configs, consolidate them using `overrides` with file glob patterns, or run oxfmt separately per directory with different working directories.
### Prettier-Compatible Options
These options transfer directly with the same behavior:
`tabWidth`, `useTabs`, `semi`, `singleQuote`, `jsxSingleQuote`, `quoteProps`, `trailingComma`, `arrowParens`, `bracketSpacing`, `bracketSameLine`, `endOfLine`, `proseWrap`, `htmlWhitespaceSensitivity`, `singleAttributePerLine`, `vueIndentScriptAndStyle`
## Step 3: Configure Oxfmt Extensions
Oxfmt offers features not available in Prettier:
### sortImports
Sort import statements, inspired by `eslint-plugin-perfectionist/sort-imports` (disabled by default):
```json
{
"sortImports": {
"partitionByNewline": true,
"newlinesBetween": false
}
}
```
### sortTailwindcss
Replaces `prettier-plugin-tailwindcss`. Auto-migrated with renamed options:
| Prettier (top-level) | oxfmt (`sortTailwindcss.*`) |
| ---------------------------- | --------------------------- |
| `tailwindConfig` | `config` |
| `tailwindStylesheet` | `stylesheet` |
| `tailwindFunctions` | `functions` |
| `tailwindAttributes` | `attributes` |
| `tailwindPreserveWhitespace` | `preserveWhitespace` |
| `tailwindPreserveDuplicates` | `preserveDuplicates` |
### Other Extensions
| Option | Default | Description |
| -------------------- | ------- | ---------------------------------------------------------------------------- |
| `insertFinalNewline` | `true` | Whether to add a final newline at end of file |
| `sortPackageJson` | `true` | Sort `package.json` keys. Set `{ "sortScripts": true }` to also sort scripts |
## Step 4: Update CI and Scripts
Replace formatter commands with oxfmt:
```bash
# Before (Prettier)
npx prettier --write .
npx prettier --check .
# Before (Biome)
npx biome format --write .
npx biome check .
# After
npx oxfmt@latest
npx oxfmt@latest --check
```
### Common CLI Options
| Prettier / Biome | oxfmt |
| ----------------------------------------------- | -------------------------------------------- |
| `prettier --write .` / `biome format --write .` | `oxfmt` (default: cwd, `--write` mode) |
| `prettier --check .` / `biome check .` | `oxfmt --check` |
| `prettier --list-different .` | `oxfmt --list-different` |
| `prettier --config path` | `oxfmt --config path` |
| `prettier --ignore-path .prettierignore` | `oxfmt --ignore-path .prettierignore` |
| `cat file \| prettier --stdin-filepath=file.ts` | `cat file \| oxfmt --stdin-filepath=file.ts` |
### File Type Coverage
- JS/TS: Formatted natively by oxfmt
- TOML: Formatted natively (via taplo)
- CSS, HTML, YAML, Markdown, GraphQL, etc.: Delegated to Prettier internally (when using `npx oxfmt`)
## Tips
- EditorConfig: Oxfmt reads `.editorconfig` automatically for `useTabs`, `tabWidth`, `endOfLine`, `insertFinalNewline`, and `printWidth`. Options in `.oxfmtrc.json` take precedence.
- CI: Use `npx oxfmt@latest --check` to enforce formatting in CI.
- LSP: Run `oxfmt --lsp` for editor integration via Language Server Protocol.
- Schema support: Add `"$schema": "./node_modules/oxfmt/configuration_schema.json"` to `.oxfmtrc.json` for editor autocompletion.
- Init: Run `npx oxfmt@latest --init` to create a default `.oxfmtrc.json` without migration.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/formatter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html)
- [Unsupported Features](https://oxc.rs/docs/guide/usage/formatter/unsupported-features.html)
---
name: migrate-oxlint
description: Guide for migrating a project from ESLint to Oxlint. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's linter from ESLint to Oxlint.
---
This skill guides you through migrating a JavaScript/TypeScript project from ESLint to [Oxlint](https://oxc.rs/docs/guide/usage/linter/).
## Overview
Oxlint is a high-performance linter that implements many popular ESLint rules natively in Rust. It can be used alongside ESLint or as a full replacement.
An official migration tool is available, and will be used by this skill: [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate)
## Step 1: Run Automated Migration
Run the migration tool in the project root:
```bash
npx @oxlint/migrate
```
This reads your ESLint flat config (`eslint.config.js` for example) and generates a `.oxlintrc.json` file from it. It will find your ESLint config file automatically in most cases.
See options below for more info.
### Key Options
| Option | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--type-aware` | Include type-aware rules from `@typescript-eslint` (will require the `oxlint-tsgolint` package to be installed after migrating) |
| `--with-nursery` | Include experimental rules still under development, may not be fully stable or consistent with ESLint equivalents |
| `--js-plugins [bool]` | Enable/disable ESLint plugin migration via `jsPlugins` (default: enabled) |
| `--details` | List rules that could not be migrated |
| `--replace-eslint-comments` | Convert all `// eslint-disable` comments to `// oxlint-disable` |
| `--output-file <file>` | Specify a different output path (default: `.oxlintrc.json`) |
If your ESLint config is not at the default location, pass the path explicitly:
```bash
npx @oxlint/migrate ./path/to/eslint.config.js
```
## Step 2: Review Generated Config
After migration, review the generated `.oxlintrc.json`.
### Plugin Mapping
The migration tool automatically maps ESLint plugins to oxlint's built-in equivalents. The following table is for reference when reviewing the generated config:
| ESLint Plugin | Oxlint Plugin Name |
| --------------------------------------------------- | ------------------ |
| `@typescript-eslint/eslint-plugin` | `typescript` |
| `eslint-plugin-react` / `eslint-plugin-react-hooks` | `react` |
| `eslint-plugin-import` / `eslint-plugin-import-x` | `import` |
| `eslint-plugin-unicorn` | `unicorn` |
| `eslint-plugin-jsx-a11y` | `jsx-a11y` |
| `eslint-plugin-react-perf` | `react-perf` |
| `eslint-plugin-promise` | `promise` |
| `eslint-plugin-jest` | `jest` |
| `@vitest/eslint-plugin` | `vitest` |
| `eslint-plugin-jsdoc` | `jsdoc` |
| `eslint-plugin-next` | `nextjs` |
| `eslint-plugin-node` | `node` |
| `eslint-plugin-vue` | `vue` |
Default plugins (enabled when `plugins` field is omitted): `unicorn`, `typescript`, `oxc`.
Setting the `plugins` array explicitly overrides these defaults.
ESLint core rules are usable in oxlint without needing to configure a plugin in the config file.
### Rule Categories
Oxlint groups rules into categories for bulk configuration, though only `correctness` is enabled by default:
```json
{
"categories": {
"correctness": "error",
"suspicious": "warn"
}
}
```
Available categories: `correctness` (default: enabled), `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`.
Individual rule settings in `rules` override category settings.
`@oxlint/migrate` will turn `correctness` off to avoid enabling additional rules that weren't enabled by your ESLint config. You can choose to enable additional categories after migration if desired.
### Check Unmigrated Rules
Run with `--details` to see which ESLint rules could not be migrated:
```bash
npx @oxlint/migrate --details
```
Review the output and decide whether to keep ESLint for those rules or not. Some rules may be mentioned in the output from `--details` as having equivalents in oxlint that were not automatically mapped by the migration tool. In those cases, consider enabling the equivalent oxlint rule manually after migration.
## Step 3: Install Oxlint
Install the core oxlint package (use `yarn install`, `pnpm install`, `vp install`, `bun install`, etc. depending on your package manager):
```bash
npm install -D oxlint
```
If you want to add the `oxlint-tsgolint` package, if you intend to use type-aware rules that require TypeScript type information:
```bash
npm install -D oxlint-tsgolint
```
No other packages besides the above are needed by default, though you will need to keep/install any additional ESLint plugins that were migrated into `jsPlugins`. Do not add `@oxlint/migrate` to the package.json, it is meant for one-off usage.
## Step 4: Handle Unsupported Features
Some features require manual attention:
- Local plugins (relative path imports): Must be migrated manually to `jsPlugins`
- `eslint-plugin-prettier`: Supported, but very slow. It is recommended to use [oxfmt](https://oxc.rs/docs/guide/usage/formatter) instead, or switch to `prettier --check` as a separate step alongside oxlint.
- `settings` in override configs: Oxlint does not support `settings` inside `overrides` blocks.
- ESLint v9+ plugins: Not all work with oxlint's JS Plugins API, but the majority will.
### Local Plugins
If you have any custom ESLint rules in the project repo itself, you can migrate them manually after running the migration tool by adding them to the `jsPlugins` field in `.oxlintrc.json`:
```json
{
"jsPlugins": ["./path/to/my-plugin.js"],
"rules": {
"local-plugin/rule-name": "error"
}
}
```
### External ESLint Plugins
For ESLint plugins without a built-in oxlint equivalent, use the `jsPlugins` field to load them:
```json
{
"jsPlugins": ["eslint-plugin-custom"],
"rules": {
"custom/my-rule": "warn"
}
}
```
## Step 5: Update CI and Scripts
Replace ESLint commands with oxlint. Path arguments are optional; oxlint defaults to the current working directory.
```bash
# Before
npx eslint src/
npx eslint --fix src/
# After
npx oxlint src/
npx oxlint --fix src/
```
### Common CLI Options
| ESLint | oxlint equivalent |
| ------------------------- | ---------------------------------------------- |
| `eslint .` | `oxlint` (default: lints the cwd) |
| `eslint src/` | `oxlint src/` |
| `eslint --fix` | `oxlint --fix` |
| `eslint --max-warnings 0` | `oxlint --deny-warnings` or `--max-warnings 0` |
| `eslint --format json` | `oxlint --format json` |
Additional oxlint options:
- `--tsconfig <path>`: Specify tsconfig.json path, likely unnecessary unless you have a non-standard name for `tsconfig.json`.
## Tips
- You can run alongside ESLint if necessary: Oxlint is designed to complement ESLint during migration, but with JS Plugins many projects can switch over fully without losing many rules.
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` when running @oxlint/migrate to convert them to `// oxlint-disable` equivalents if desired.
- List available rules: Run `npx oxlint --rules` to see all supported rules, or refer to the [rule documentation](https://oxc.rs/docs/guide/usage/linter/rules.html).
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion if the migration tool didn't do it automatically.
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
- Ignore files: `.eslintignore` is supported by oxlint if you have it, but it's recommended to move any ignore patterns into the `ignorePatterns` field in `.oxlintrc.json` for consistency and simplicity. All files and paths ignored via a `.gitignore` file will be ignored by oxlint by default as well.
- If you ran the migration tool multiple times, remove the `.oxlintrc.json.bak` backup file created by the migration tool once you've finished migrating.
- If you are not using any JS Plugins and have replaced your ESLint configuration, you can remove all ESLint packages from your project dependencies.
- Ensure your editor is configured to use oxlint instead of ESLint for linting and error reporting. You may want to install the Oxc extension for your preferred editor. See https://oxc.rs/docs/guide/usage/linter/editors.html for more details.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/linter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html)
- [Complete Oxlint rule list and docs](https://oxc.rs/docs/guide/usage/linter/rules.html)
---
description: 样式编写优先使用 UnoCSS,必要时再写 style 标签
alwaysApply: true
---
# UnoCSS First(本仓库样式规则)
## 核心原则
- 默认优先使用 **UnoCSS 原子类**完成样式。
- 仅在 UnoCSS 难以清晰表达、复用困难、或需要兼容兜底时,才写 `<style>`(含 scoped)。
## 推荐优先使用 UnoCSS 的场景
- 布局与结构:`flex`、`grid`、定位、间距、尺寸、圆角、层级
- 常规视觉:文字颜色、普通背景色、边框、阴影、过渡
- 组件状态样式:hover/active/disabled 的常见状态类
## 可以改用 `<style>` 的场景(例外)
- 复杂选择器或嵌套层级关系,使用原子类会明显降低可读性
- 需要伪元素、复杂 keyframes、媒体查询细分、`@supports` 等
- 需要写浏览器兼容兜底(例如关键按钮渐变的实色兜底)
- 同一段长样式在多个页面复用,抽成语义化 class 更清晰
## 代码评审偏好
- 若 UnoCSS 能清楚表达,不新增手写 CSS
- 新增 `<style>` 时,附一行简短注释说明“为什么不用 UnoCSS”
- 避免“少量 Uno + 大量零散手写”混搭导致维护成本上升
---
description: Vue 响应式优先使用 computed,谨慎使用 watch
alwaysApply: true
---
# Vue:Prefer computed over watch
## 一句话规则
能用 `computed` 实现,就不要用 `watch`;`watch` 仅用于副作用或外部系统同步。
副作用优先在显式事件回调中触发;仅当无法显式建模时,才使用 `watch` 承担副作用。
## 选择顺序(按此判断)
1. 这是“值到值”的派生吗?是 -> `computed`
2. 这是“变化后要做动作”吗(请求/跳路由/打点/DOM/存储)?是 -> `watch`/`watchEffect`
3. 两者都不是 -> 优先重构数据流,避免堆叠 `watch`
## 为什么这样做
- `computed` 声明式、可推导、可缓存,维护成本低。
- `watch` 天然偏副作用,链路更隐式,调试成本更高。
- 响应式状态常有多处修改来源(业务逻辑/组件库/异步回填),`watch` 过多时很容易失控。
## 实践要求
- 纯展示状态:必须优先 `computed`。
- 使用 `watch` 时,必须满足:
- 监听源明确(避免无必要 `deep`)。
- 副作用目标明确(建议加 1 行注释说明触发目的)。
- 避免在回调里同时隐式修改多个状态,降低连锁触发。
## 允许使用 watch 的典型场景
- 参数变化触发请求(含取消旧请求)
- 与路由 query / localStorage / sessionStorage 同步
- 驱动第三方 imperative API(图表、编辑器、播放器等)
- 需要 old/new 对比做流程控制
## 推荐替代思路(watch 过重时)
- `computed + 显式事件触发`
- 拆分状态来源,减少“一个 watch 做所有事”
{
"$schema": "https://json.schemastore.org/prettierrc",
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true,
"printWidth": 100
"printWidth": 100,
"sortPackageJson": false,
"ignorePatterns": []
}
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"vue",
"typescript",
"unicorn"
],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true
},
"ignorePatterns": [
"**/dist/**",
"**/dist-ssr/**",
"**/coverage/**"
],
"rules": {
"vue/no-arrow-functions-in-watch": "error",
"vue/no-deprecated-destroyed-lifecycle": "error",
"vue/no-export-in-script-setup": "error",
"vue/no-lifecycle-after-await": "error",
"vue/prefer-import-from-vue": "error",
"vue/valid-define-emits": "error",
"vue/valid-define-props": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"no-unused-vars": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts",
"**/*.vue"
],
"rules": {
"constructor-super": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
}
]
}
\ No newline at end of file
......@@ -3,6 +3,6 @@
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
"oxc.oxc-vscode"
]
}
......@@ -24,10 +24,10 @@ const zipPath = path.resolve(__dirname, 'dist.tar.gz')
const { spawn, servicePath, serviceFilePath, unzipDir } = unzipDirMode
// 服务器连接信息
const connectInfo = {
host: '', // 服务器地址
port: '22',
username: 'root',
password: '', // 服务器密码
host: process.env.DEPLOY_PROD_HOST, // 服务器地址
port: process.env.DEPLOY_PROD_PORT,
username: process.env.DEPLOY_PROD_USERNAME,
password: process.env.DEPLOY_PROD_PASSWORD, // 服务器密码
}
//链接服务器
let conn = new ssh.Client()
......@@ -157,7 +157,7 @@ function connect() {
if (err) {
return reject(err)
}
sftp.fastPut(zipPath, serviceFilePath, {}, (err, result) => {
sftp.fastPut(zipPath, serviceFilePath, {}, (err) => {
if (err) {
return reject(err)
}
......
import cp from 'node:child_process'
import ssh from 'ssh2'
import archiver from 'archiver'
import fs from 'node:fs'
import path from 'node:path'
import cp from "node:child_process";
import ssh from "ssh2";
import archiver from "archiver";
import fs from "node:fs";
import path from "node:path";
import { getCurrentBranchName, getDeploySlug } from "./util.js";
// 当前分支名
const branchName = getCurrentBranchName();
// 服务器路径名
const deploySlug = getDeploySlug(branchName);
// 最多部署的分支个数
const MAX_DEPLOY_BRANCH_COUNT = 5;
const connectInfo = {
host: "47.119.149.50",
port: "22",
username: "root",
password: "Qdt20250205",
};
const remoteRootPath = "/usr/local/nginx/www/culture";
const remoteBranchPath = `${remoteRootPath}/${deploySlug}`;
const remoteTarPath = `${remoteBranchPath}/dist.tar.gz`;
const remoteMetaPath = `${remoteBranchPath}/.deploy-meta.json`;
const unzipDirMode = {
spawn: 'npm run build:test',
// 解压文件名
unzipDir: 'culture/',
// 终端定位位置 cd
servicePath: '/usr/local/nginx/',
// 压缩包存放位置
serviceFilePath: '/usr/local/nginx/dist.tar.gz',
spawn: "npm run build:test",
servicePath: remoteRootPath,
unzipDir: deploySlug,
serviceFilePath: remoteTarPath,
};
const __dirname = path.resolve();
const distPath = path.resolve(__dirname, "dist");
const zipPath = path.resolve(__dirname, "dist.tar.gz");
const { spawn, servicePath, unzipDir, serviceFilePath } = unzipDirMode;
let conn = null;
function connectServer() {
return new Promise((resolve, reject) => {
conn = new ssh.Client();
conn
.on("ready", () => {
console.log("SSH 连接成功");
resolve(conn);
})
.on("error", (err) => {
reject(err);
})
.connect(connectInfo);
});
}
const __dirname = path.resolve()
// 文件所在地
const distPath = path.resolve(__dirname, 'dist')
// 打包后位置
const zipPath = path.resolve(__dirname, 'dist.tar.gz')
// 远程服务器存放位置
const { spawn, servicePath, serviceFilePath, unzipDir } = unzipDirMode
// 服务器连接信息
const connectInfo = {
host: '47.119.149.50',
port: '22',
username: 'root',
password: 'Qdt20250205',
function execCommand(command) {
return new Promise((resolve, reject) => {
conn.exec(command, { encoding: "utf-8" }, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = "";
let stderr = "";
stream.on("data", (data) => {
stdout += data.toString();
});
stream.stderr.on("data", (data) => {
stderr += data.toString();
});
stream.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(stderr.trim() || `命令执行失败: ${command}`));
});
});
});
}
// 获取远程服务器已部署的分支文件夹列表
async function getFolderList() {
const result = await execCommand(
`find ${remoteRootPath} -mindepth 1 -maxdepth 1 -type d -printf "%f\\n"`,
);
return result
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
}
// 获取已部署的分支的元信息 中文名 服务器路径名 部署时间
async function getDeployEntries(folderList) {
const entries = await Promise.all(
folderList.map(async (slug) => {
const metaFilePath = `${remoteRootPath}/${slug}/.deploy-meta.json`;
try {
const rawMeta = await execCommand(`cat '${metaFilePath}'`);
const meta = JSON.parse(rawMeta);
return {
slug,
branchName: meta.branchName || slug,
deployedAt: meta.deployedAt || "",
};
} catch {
return {
slug,
branchName: slug,
deployedAt: "",
};
}
}),
);
return entries.sort((a, b) => a.branchName.localeCompare(b.branchName, "zh-CN"));
}
// 写入index.html文件
async function writeIndexHtml(entries) {
// 剔除掉已经部署的
const dedupedEntries = [];
const seenSlugs = new Set();
for (const entry of [{ slug: deploySlug, branchName }, ...entries]) {
if (seenSlugs.has(entry.slug)) {
continue;
}
seenSlugs.add(entry.slug);
dedupedEntries.push(entry);
}
const indexHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>culture</title>
<style>
:root {
--bg: linear-gradient(135deg, #f4fbf8 0%, #e9f6ff 100%);
--card-bg: rgba(255, 255, 255, 0.88);
--card-border: rgba(137, 196, 181, 0.24);
--text-main: #16332d;
--text-sub: #5e7b74;
--button-bg: linear-gradient(135deg, #8be2c8 0%, #63d4c5 100%);
--button-shadow: 0 10px 24px rgba(99, 212, 197, 0.22);
--button-shadow-hover: 0 14px 28px rgba(99, 212, 197, 0.3);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text-main);
}
.page {
width: min(960px, calc(100vw - 32px));
margin: 0 auto;
padding: 40px 0 56px;
}
.hero {
margin-bottom: 24px;
padding: 28px 28px 24px;
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--card-bg);
backdrop-filter: blur(10px);
box-shadow: 0 20px 50px rgba(87, 143, 130, 0.12);
}
.hero h1 {
margin: 0 0 10px;
font-size: 28px;
line-height: 1.2;
}
.hero p {
margin: 0;
color: var(--text-sub);
font-size: 14px;
line-height: 1.7;
}
.account-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(137, 196, 181, 0.18);
}
.account-label {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
.account-input {
flex: 1 1 260px;
min-width: 220px;
height: 44px;
padding: 0 14px;
border: 1px solid rgba(108, 180, 163, 0.28);
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
color: var(--text-main);
font-size: 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.account-input:focus {
border-color: rgba(74, 180, 152, 0.8);
box-shadow: 0 0 0 4px rgba(116, 211, 187, 0.14);
}
.account-tip {
width: 100%;
margin: 0;
color: var(--text-sub);
font-size: 12px;
line-height: 1.6;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.branch-card {
padding: 18px;
border: 1px solid rgba(137, 196, 181, 0.2);
border-radius: 20px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 14px 30px rgba(126, 178, 167, 0.1);
}
.branch-name {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.branch-slug {
margin: 0 0 16px;
color: var(--text-sub);
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.branch-button {
width: 100%;
border: 0;
border-radius: 14px;
padding: 12px 16px;
background: var(--button-bg);
color: #13312a;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--button-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
.branch-button:hover {
transform: translateY(-2px);
box-shadow: var(--button-shadow-hover);
filter: saturate(1.05);
}
.branch-button:active {
transform: translateY(0);
}
@media (max-width: 640px) {
.page {
width: min(100vw - 20px, 960px);
padding-top: 20px;
}
.hero {
padding: 22px 18px 18px;
border-radius: 18px;
}
.hero h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<h1>Culture 分支导航</h1>
<p>这里展示当前可访问的测试分支,点击卡片按钮即可进入对应环境。</p>
<div class="account-panel">
<label class="account-label" for="accountInput">当前账号</label>
<input
id="accountInput"
class="account-input"
type="text"
placeholder="请输入账号"
/>
<p class="account-tip">账号会自动保存在浏览器本地,后续点击任意分支都会带上当前输入的 code 参数。</p>
</div>
</section>
<div class="grid">
${dedupedEntries
.map(
(item) => `
<section class="branch-card">
<h2 class="branch-name">分支「${item.branchName}」</h2>
<p class="branch-slug">访问路径:/${item.slug}/</p>
<button class="branch-button" onclick="goToBranch('${item.slug}')">
进入当前分支
</button>
</section>`,
)
.join("\n")}
</div>
</div>
<script>
const STORAGE_KEY = 'culture_account_code'
const DEFAULT_ACCOUNT = 'lijiabin'
const accountInput = document.getElementById('accountInput')
function getAccountCode() {
const storedValue = window.localStorage.getItem(STORAGE_KEY)
return storedValue && storedValue.trim() ? storedValue.trim() : DEFAULT_ACCOUNT
}
function setAccountCode(value) {
const finalValue = value && value.trim() ? value.trim() : DEFAULT_ACCOUNT
window.localStorage.setItem(STORAGE_KEY, finalValue)
if (accountInput && accountInput.value !== finalValue) {
accountInput.value = finalValue
}
return finalValue
}
function goToBranch(slug) {
const code = encodeURIComponent(getAccountCode())
window.location.href = '/' + slug + '/?code=' + code + '@yswg.com.cn'
}
if (accountInput) {
accountInput.value = getAccountCode()
accountInput.addEventListener('input', (event) => {
setAccountCode(event.target.value)
})
accountInput.addEventListener('blur', (event) => {
setAccountCode(event.target.value)
})
}
setAccountCode(getAccountCode())
</script>
</body>
</html>
`;
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) {
reject(new Error(`SFTP Error: ${err.message}`));
return;
}
const writeStream = sftp.createWriteStream(`${remoteRootPath}/index.html`, {
encoding: "utf8",
});
writeStream.on("error", (error) => {
sftp.end();
reject(new Error(`写入失败: ${error.message}`));
});
writeStream.on("close", () => {
console.log("index.html 写入成功");
sftp.end();
resolve();
});
writeStream.end(indexHtml);
});
});
}
// 写入部署元信息
async function writeDeployMeta() {
const meta = {
branchName,
deploySlug,
deployedAt: new Date().toLocaleString("zh-CN", {
hour12: false,
timeZone: "Asia/Shanghai",
}),
};
const metaContent = JSON.stringify(meta, null, 2);
await execCommand(`cat <<'EOF' > '${remoteMetaPath}'
${metaContent}
EOF`);
}
//链接服务器
let conn = new ssh.Client()
async function start() {
try {
// 1. 打包
await build()
// 2. 压缩zip
await startZip()
// 3. 将zip文件传输至远程服务器
await connect()
// 4. 部署解压
await shellCmd(conn)
console.log('部署完成')
await connectServer();
const folderList = await getFolderList();
const deployEntries = await getDeployEntries(folderList);
const alreadyDeployed = deployEntries.some((item) => item.slug === deploySlug);
if (!alreadyDeployed && deployEntries.length >= MAX_DEPLOY_BRANCH_COUNT) {
console.log(
`当前已部署分支数量已达到上限 ${MAX_DEPLOY_BRANCH_COUNT},请先手动删除以下分支中的一个或多个:`,
);
deployEntries.forEach((item, index) => {
console.log(`${index + 1}. ${item.branchName} (${item.slug})`);
});
return;
}
await writeIndexHtml(deployEntries);
await build();
await startZip();
await uploadTar();
await shellCmd();
await writeDeployMeta();
console.log(`部署完成,当前分支「${branchName}」对应目录:${deploySlug}`);
} catch (error) {
console.error('Error:', error.message)
console.error("Error:", error.message);
} finally {
// 5. 断开ssh,并删除本地压缩包
conn.end()
delZip()
if (conn) {
conn.end();
}
delZip();
}
}
start()
/**
* 1. 本地构建项目
*/
start();
function build() {
return new Promise((resolve, reject) => {
//对项目进行打包,然后生成压缩文件
let pro = cp.spawn(spawn, {
const pro = cp.spawn(spawn, {
shell: true,
stdio: 'inherit',
})
pro.on('exit', (code) => {
//打包完成后 开始链接目标服务器,并自动部署
stdio: "inherit",
});
pro.on("exit", (code) => {
if (code === 0) {
console.log('---构建成功---')
resolve()
} else {
reject(new Error('构建失败'))
console.log("---构建成功---");
resolve();
return;
}
})
})
reject(new Error("构建失败"));
});
});
}
/**
* 2. 将打包后文件压缩zip
* @returns
*/
function startZip() {
return new Promise((resolve, reject) => {
console.log('开始打包tar')
//定义打包格式和相关配置
const archive = archiver('tar', {
gzip: true, // 如果需要压缩,可以使用 gzip
gzipOptions: { level: 9 }, // gzip 压缩级别
}).on('error', (err) => reject(err))
console.log('zipPath', zipPath)
const output = fs.createWriteStream(zipPath)
//监听流的打包
output.on('close', (err) => {
console.log('err', err)
console.log('目标打包完成')
resolve(true)
})
//开始压缩
archive.pipe(output)
// 文件夹压缩
archive.directory(distPath, false)
archive.finalize()
})
}
console.log("开始打包 tar");
/**
* 3. 将zip文件传输至远程服务器
*/
function connect() {
return new Promise((resolve, reject) => {
conn
.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) {
return reject(err)
}
sftp.fastPut(zipPath, serviceFilePath, {}, (err, result) => {
if (err) {
return reject(err)
}
//开始上传
console.log('文件上传成功')
resolve()
})
})
})
.on('error', (err) => reject(err))
.connect(connectInfo)
})
const archive = archiver("tar", {
gzip: true,
gzipOptions: { level: 9 },
});
archive.on("error", (err) => {
reject(err);
});
console.log("zipPath", zipPath);
const output = fs.createWriteStream(zipPath);
output.on("close", () => {
console.log("目标打包完成");
resolve(true);
});
output.on("error", (err) => {
reject(err);
});
archive.pipe(output);
archive.directory(distPath, false);
archive.finalize();
});
}
/**
* 4. 解压部署操作
* @param {*} conn
*/
async function shellCmd(conn) {
async function uploadTar() {
await execCommand(`mkdir -p ${servicePath}/${unzipDir}`);
return new Promise((resolve, reject) => {
conn.shell((err, stream) => {
conn.sftp((err, sftp) => {
if (err) {
return reject(err)
reject(err);
return;
}
//进入服务器暂存地址
//解压上传的压缩包
//移动解压后的文件到发布目录
//删除压缩包
//退出
const commands = `
cd ${servicePath} &&
mkdir -p ${unzipDir} &&
tar -xzf dist.tar.gz -C ${unzipDir} &&
rm -rf dist.tar.gz &&
exit
`
console.log('终端执行命令:', commands)
stream
.on('close', () => resolve())
.on('data', (data) => console.log(data.toString()))
.stderr.on('data', (data) => console.error('Error:', data.toString()))
stream.end(commands)
})
})
sftp.fastPut(zipPath, serviceFilePath, {}, (putErr) => {
sftp.end();
if (putErr) {
reject(putErr);
return;
}
console.log("文件上传成功");
resolve();
});
});
});
}
async function shellCmd() {
const command = `cd ${remoteBranchPath} && tar -xzf dist.tar.gz && rm -f dist.tar.gz`;
console.log("终端执行命令:", command);
await execCommand(command);
}
/**
* 5. 删除本地的dist.zip
*/
function delZip() {
fs.unlink(zipPath, function (err) {
if (!fs.existsSync(zipPath)) {
return;
}
fs.unlink(zipPath, (err) => {
if (err) {
console.error('删除文件失败:', err.message)
console.error("删除文件失败:", err.message);
return;
}
console.log('文件:' + zipPath + '删除成功!')
})
console.log(`文件: ${zipPath} 删除成功!`);
});
}
import { execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
// 获取当前分支名 去掉空格 斜杠 换行 等特殊字符
export const getCurrentBranchName = () => {
const branchName = execSync('git branch --show-current', {
encoding: 'utf-8',
})
.trim()
.replaceAll(' ', '_')
.replaceAll('/', '_')
.replaceAll('\n', '_')
return branchName
}
// 根据分支名创建一个唯一的服务器文件夹名字
export const getDeploySlug = (branchName) => {
return `branch-${createHash('md5').update(branchName).digest('hex').slice(0, 8)}`
}
......@@ -20,3 +20,16 @@ declare module '@wangeditor/editor-for-vue' {
const Toolbar: any
export { Editor, Toolbar }
}
declare module 'textarea-caret' {
interface CaretCoordinates {
top: number
left: number
height: number
}
export default function getCaretCoordinates(
element: HTMLTextAreaElement | HTMLInputElement,
position: number,
): CaretCoordinates
}
// Bridge for `npx @oxlint/migrate` (it cannot load TypeScript). ESLint still uses `eslint.config.ts`.
import { createJiti } from 'jiti'
const jiti = createJiti(import.meta.url)
export default jiti('./eslint.config.ts').default
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/webicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企业文化平台</title>
</head>
<head>
<meta charset="UTF-8">
<link rel="icon" href="/webicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业文化平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
\ No newline at end of file
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
......@@ -11,18 +11,22 @@
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix --cache",
"format": "prettier --write src/",
"lint": "oxlint --fix && eslint . --fix --cache",
"lint:oxlint": "oxlint --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "oxfmt",
"format:check": "oxfmt --check",
"build-only": "vite build",
"build:dev": "nvm use 20 && vite build --mode development",
"build:test": "nvm use 20 && vite build --mode test",
"deploy:test": "node deploy/deploytest.js",
"build:prod": "nvm use 20 && vite build --mode production",
"deploy:prod": "node deploy/deployprod.js",
"deploy:prod:update-info": "node deploy/deployprod.js --update-info"
"deploy:prod": "node --env-file=.env.local deploy/deployprod.js",
"deploy:prod:update-info": "node --env-file=.env.local deploy/deployprod.js --update-info"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@lucky-canvas/vue": "^0.1.11",
"@types/crypto-js": "^4.2.2",
"@vueuse/components": "^14.0.0",
"@vueuse/core": "^14.0.0",
......@@ -34,10 +38,12 @@
"crypto-js": "^4.2.0",
"dayjs": "^1.11.19",
"element-plus": "^2.11.5",
"gsap": "^3.14.2",
"inquirer": "^13.0.2",
"notivue": "^2.4.5",
"pinia": "^3.0.3",
"ssh2": "^1.17.0",
"textarea-caret": "^3.1.0",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"xlsx": "^0.18.5"
......@@ -47,7 +53,7 @@
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.11",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
......@@ -56,7 +62,8 @@
"eslint-plugin-vue": "~10.5.0",
"jiti": "^2.6.1",
"npm-run-all2": "^8.0.4",
"prettier": "3.6.2",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"rollup-plugin-visualizer": "^6.0.5",
"sass-embedded": "^1.93.2",
"typescript": "~5.9.0",
......@@ -64,7 +71,7 @@
"unplugin-auto-import": "^20.2.0",
"unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.1.11",
"vite": "^8.0.0",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-devtools": "^8.0.3",
"vue-tsc": "^3.1.1"
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"version": 1,
"skills": {
"migrate-oxfmt": {
"source": "oxc-project/oxc",
"sourceType": "github",
"computedHash": "b3ae0d11b61d07471cf466ba0386490a9f7ed5c1186d4956a58ac55a4b6be6ad"
},
"migrate-oxlint": {
"source": "oxc-project/oxc",
"sourceType": "github",
"computedHash": "1f852a179bed024d1a65c73d345436ab28399f586e34fdcfd9602ed2c73d1fdc"
},
"vue": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "f72b54618560c53d65740515ded5bb833750c4574dbe961fd21f022bd2b31a74"
}
}
}
---
name: migrate-oxfmt
description: Guide for migrating a project from Prettier or Biome to Oxfmt. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's formatter from Prettier or Biome to Oxfmt.
---
This skill guides you through migrating a JavaScript/TypeScript project from Prettier or Biome to [Oxfmt](https://oxc.rs/docs/guide/usage/formatter).
## Overview
Oxfmt is a high-performance, Prettier-compatible code formatter. Most Prettier options are supported directly.
An automated migration tool is built into oxfmt, supporting both Prettier and Biome as migration sources.
## Step 1: Run Automated Migration
### From Prettier
```bash
npx oxfmt@latest --migrate prettier
```
This will:
- Find and read your Prettier config (any format Prettier supports)
- Create `.oxfmtrc.json` with migrated options
- Migrate `.prettierignore` patterns to `ignorePatterns`
- Migrate `prettier-plugin-tailwindcss` options to `sortTailwindcss`
- Detect `prettier-plugin-packagejson` and enable `sortPackageJson`
### From Biome
```bash
npx oxfmt@latest --migrate biome
```
This will:
- Find and read `biome.json` or `biome.jsonc`
- Create `.oxfmtrc.json` with migrated options
- Migrate negated patterns from `files.includes` to `ignorePatterns`
- Map Biome's two-level config (`formatter.*` and `javascript.formatter.*`) to oxfmt options
Biome option mapping:
| Biome | oxfmt |
| ----------------------------------------------------------- | --------------------------------- |
| `formatter.indentStyle` (`"tab"`/`"space"`) | `useTabs` (`true`/`false`) |
| `formatter.indentWidth` | `tabWidth` |
| `formatter.lineWidth` | `printWidth` |
| `javascript.formatter.quoteStyle` | `singleQuote` |
| `javascript.formatter.jsxQuoteStyle` | `jsxSingleQuote` |
| `javascript.formatter.quoteProperties` (`"asNeeded"`) | `quoteProps` (`"as-needed"`) |
| `javascript.formatter.trailingCommas` | `trailingComma` |
| `javascript.formatter.semicolons` (`"always"`/`"asNeeded"`) | `semi` (`true`/`false`) |
| `javascript.formatter.arrowParentheses` (`"asNeeded"`) | `arrowParens` (`"avoid"`) |
| `formatter.bracketSameLine` | `bracketSameLine` |
| `formatter.bracketSpacing` | `bracketSpacing` |
| `formatter.attributePosition` (`"multiline"`) | `singleAttributePerLine` (`true`) |
Notes (both sources):
- Fails if `.oxfmtrc.json` already exists. Delete it first if you want to re-run.
- If no source config is found, creates a blank `.oxfmtrc.json` instead.
- `overrides` cannot be auto-migrated for either source and must be converted manually.
## Step 2: Review Generated Config
After migration, review the generated `.oxfmtrc.json` for these key differences:
### printWidth
Prettier and Biome default is 80, oxfmt default is 100. The migration tool sets `printWidth: 80` if not specified in your source config. Decide whether to keep 80 or adopt 100.
### Unsupported Options (Prettier only)
These Prettier options are skipped during migration:
| Option | Status |
| ------------------------------ | ------------------------------------------------ |
| `endOfLine: "auto"` | Not supported. Use `"lf"` or `"crlf"` explicitly |
| `experimentalTernaries` | Not supported in JS/TS files yet |
| `experimentalOperatorPosition` | Not supported in JS/TS files yet |
### sortPackageJson (Prettier only)
Enabled by default in oxfmt, but the migration tool disables it unless `prettier-plugin-packagejson` was detected. Review whether you want this enabled.
Note: Oxfmt's sorting algorithm differs from `prettier-plugin-packagejson`.
### embeddedLanguageFormatting (Prettier only)
Embedded language formatting (e.g., CSS-in-JS) generally works, but some formatting may differ from Prettier.
### overrides
The `overrides` field cannot be auto-migrated from either Prettier or Biome. Convert manually:
```json
{
"overrides": [
{
"files": ["*.md"],
"options": { "tabWidth": 4 }
}
]
}
```
### Nested Config
Oxfmt does not support nested configuration files (e.g., a separate `.oxfmtrc.json` in a subdirectory). If your project used per-directory Prettier or Biome configs, consolidate them using `overrides` with file glob patterns, or run oxfmt separately per directory with different working directories.
### Prettier-Compatible Options
These options transfer directly with the same behavior:
`tabWidth`, `useTabs`, `semi`, `singleQuote`, `jsxSingleQuote`, `quoteProps`, `trailingComma`, `arrowParens`, `bracketSpacing`, `bracketSameLine`, `endOfLine`, `proseWrap`, `htmlWhitespaceSensitivity`, `singleAttributePerLine`, `vueIndentScriptAndStyle`
## Step 3: Configure Oxfmt Extensions
Oxfmt offers features not available in Prettier:
### sortImports
Sort import statements, inspired by `eslint-plugin-perfectionist/sort-imports` (disabled by default):
```json
{
"sortImports": {
"partitionByNewline": true,
"newlinesBetween": false
}
}
```
### sortTailwindcss
Replaces `prettier-plugin-tailwindcss`. Auto-migrated with renamed options:
| Prettier (top-level) | oxfmt (`sortTailwindcss.*`) |
| ---------------------------- | --------------------------- |
| `tailwindConfig` | `config` |
| `tailwindStylesheet` | `stylesheet` |
| `tailwindFunctions` | `functions` |
| `tailwindAttributes` | `attributes` |
| `tailwindPreserveWhitespace` | `preserveWhitespace` |
| `tailwindPreserveDuplicates` | `preserveDuplicates` |
### Other Extensions
| Option | Default | Description |
| -------------------- | ------- | ---------------------------------------------------------------------------- |
| `insertFinalNewline` | `true` | Whether to add a final newline at end of file |
| `sortPackageJson` | `true` | Sort `package.json` keys. Set `{ "sortScripts": true }` to also sort scripts |
## Step 4: Update CI and Scripts
Replace formatter commands with oxfmt:
```bash
# Before (Prettier)
npx prettier --write .
npx prettier --check .
# Before (Biome)
npx biome format --write .
npx biome check .
# After
npx oxfmt@latest
npx oxfmt@latest --check
```
### Common CLI Options
| Prettier / Biome | oxfmt |
| ----------------------------------------------- | -------------------------------------------- |
| `prettier --write .` / `biome format --write .` | `oxfmt` (default: cwd, `--write` mode) |
| `prettier --check .` / `biome check .` | `oxfmt --check` |
| `prettier --list-different .` | `oxfmt --list-different` |
| `prettier --config path` | `oxfmt --config path` |
| `prettier --ignore-path .prettierignore` | `oxfmt --ignore-path .prettierignore` |
| `cat file \| prettier --stdin-filepath=file.ts` | `cat file \| oxfmt --stdin-filepath=file.ts` |
### File Type Coverage
- JS/TS: Formatted natively by oxfmt
- TOML: Formatted natively (via taplo)
- CSS, HTML, YAML, Markdown, GraphQL, etc.: Delegated to Prettier internally (when using `npx oxfmt`)
## Tips
- EditorConfig: Oxfmt reads `.editorconfig` automatically for `useTabs`, `tabWidth`, `endOfLine`, `insertFinalNewline`, and `printWidth`. Options in `.oxfmtrc.json` take precedence.
- CI: Use `npx oxfmt@latest --check` to enforce formatting in CI.
- LSP: Run `oxfmt --lsp` for editor integration via Language Server Protocol.
- Schema support: Add `"$schema": "./node_modules/oxfmt/configuration_schema.json"` to `.oxfmtrc.json` for editor autocompletion.
- Init: Run `npx oxfmt@latest --init` to create a default `.oxfmtrc.json` without migration.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/formatter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html)
- [Unsupported Features](https://oxc.rs/docs/guide/usage/formatter/unsupported-features.html)
---
name: migrate-oxlint
description: Guide for migrating a project from ESLint to Oxlint. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's linter from ESLint to Oxlint.
---
This skill guides you through migrating a JavaScript/TypeScript project from ESLint to [Oxlint](https://oxc.rs/docs/guide/usage/linter/).
## Overview
Oxlint is a high-performance linter that implements many popular ESLint rules natively in Rust. It can be used alongside ESLint or as a full replacement.
An official migration tool is available, and will be used by this skill: [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate)
## Step 1: Run Automated Migration
Run the migration tool in the project root:
```bash
npx @oxlint/migrate
```
This reads your ESLint flat config (`eslint.config.js` for example) and generates a `.oxlintrc.json` file from it. It will find your ESLint config file automatically in most cases.
See options below for more info.
### Key Options
| Option | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--type-aware` | Include type-aware rules from `@typescript-eslint` (will require the `oxlint-tsgolint` package to be installed after migrating) |
| `--with-nursery` | Include experimental rules still under development, may not be fully stable or consistent with ESLint equivalents |
| `--js-plugins [bool]` | Enable/disable ESLint plugin migration via `jsPlugins` (default: enabled) |
| `--details` | List rules that could not be migrated |
| `--replace-eslint-comments` | Convert all `// eslint-disable` comments to `// oxlint-disable` |
| `--output-file <file>` | Specify a different output path (default: `.oxlintrc.json`) |
If your ESLint config is not at the default location, pass the path explicitly:
```bash
npx @oxlint/migrate ./path/to/eslint.config.js
```
## Step 2: Review Generated Config
After migration, review the generated `.oxlintrc.json`.
### Plugin Mapping
The migration tool automatically maps ESLint plugins to oxlint's built-in equivalents. The following table is for reference when reviewing the generated config:
| ESLint Plugin | Oxlint Plugin Name |
| --------------------------------------------------- | ------------------ |
| `@typescript-eslint/eslint-plugin` | `typescript` |
| `eslint-plugin-react` / `eslint-plugin-react-hooks` | `react` |
| `eslint-plugin-import` / `eslint-plugin-import-x` | `import` |
| `eslint-plugin-unicorn` | `unicorn` |
| `eslint-plugin-jsx-a11y` | `jsx-a11y` |
| `eslint-plugin-react-perf` | `react-perf` |
| `eslint-plugin-promise` | `promise` |
| `eslint-plugin-jest` | `jest` |
| `@vitest/eslint-plugin` | `vitest` |
| `eslint-plugin-jsdoc` | `jsdoc` |
| `eslint-plugin-next` | `nextjs` |
| `eslint-plugin-node` | `node` |
| `eslint-plugin-vue` | `vue` |
Default plugins (enabled when `plugins` field is omitted): `unicorn`, `typescript`, `oxc`.
Setting the `plugins` array explicitly overrides these defaults.
ESLint core rules are usable in oxlint without needing to configure a plugin in the config file.
### Rule Categories
Oxlint groups rules into categories for bulk configuration, though only `correctness` is enabled by default:
```json
{
"categories": {
"correctness": "error",
"suspicious": "warn"
}
}
```
Available categories: `correctness` (default: enabled), `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`.
Individual rule settings in `rules` override category settings.
`@oxlint/migrate` will turn `correctness` off to avoid enabling additional rules that weren't enabled by your ESLint config. You can choose to enable additional categories after migration if desired.
### Check Unmigrated Rules
Run with `--details` to see which ESLint rules could not be migrated:
```bash
npx @oxlint/migrate --details
```
Review the output and decide whether to keep ESLint for those rules or not. Some rules may be mentioned in the output from `--details` as having equivalents in oxlint that were not automatically mapped by the migration tool. In those cases, consider enabling the equivalent oxlint rule manually after migration.
## Step 3: Install Oxlint
Install the core oxlint package (use `yarn install`, `pnpm install`, `vp install`, `bun install`, etc. depending on your package manager):
```bash
npm install -D oxlint
```
If you want to add the `oxlint-tsgolint` package, if you intend to use type-aware rules that require TypeScript type information:
```bash
npm install -D oxlint-tsgolint
```
No other packages besides the above are needed by default, though you will need to keep/install any additional ESLint plugins that were migrated into `jsPlugins`. Do not add `@oxlint/migrate` to the package.json, it is meant for one-off usage.
## Step 4: Handle Unsupported Features
Some features require manual attention:
- Local plugins (relative path imports): Must be migrated manually to `jsPlugins`
- `eslint-plugin-prettier`: Supported, but very slow. It is recommended to use [oxfmt](https://oxc.rs/docs/guide/usage/formatter) instead, or switch to `prettier --check` as a separate step alongside oxlint.
- `settings` in override configs: Oxlint does not support `settings` inside `overrides` blocks.
- ESLint v9+ plugins: Not all work with oxlint's JS Plugins API, but the majority will.
### Local Plugins
If you have any custom ESLint rules in the project repo itself, you can migrate them manually after running the migration tool by adding them to the `jsPlugins` field in `.oxlintrc.json`:
```json
{
"jsPlugins": ["./path/to/my-plugin.js"],
"rules": {
"local-plugin/rule-name": "error"
}
}
```
### External ESLint Plugins
For ESLint plugins without a built-in oxlint equivalent, use the `jsPlugins` field to load them:
```json
{
"jsPlugins": ["eslint-plugin-custom"],
"rules": {
"custom/my-rule": "warn"
}
}
```
## Step 5: Update CI and Scripts
Replace ESLint commands with oxlint. Path arguments are optional; oxlint defaults to the current working directory.
```bash
# Before
npx eslint src/
npx eslint --fix src/
# After
npx oxlint src/
npx oxlint --fix src/
```
### Common CLI Options
| ESLint | oxlint equivalent |
| ------------------------- | ---------------------------------------------- |
| `eslint .` | `oxlint` (default: lints the cwd) |
| `eslint src/` | `oxlint src/` |
| `eslint --fix` | `oxlint --fix` |
| `eslint --max-warnings 0` | `oxlint --deny-warnings` or `--max-warnings 0` |
| `eslint --format json` | `oxlint --format json` |
Additional oxlint options:
- `--tsconfig <path>`: Specify tsconfig.json path, likely unnecessary unless you have a non-standard name for `tsconfig.json`.
## Tips
- You can run alongside ESLint if necessary: Oxlint is designed to complement ESLint during migration, but with JS Plugins many projects can switch over fully without losing many rules.
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` when running @oxlint/migrate to convert them to `// oxlint-disable` equivalents if desired.
- List available rules: Run `npx oxlint --rules` to see all supported rules, or refer to the [rule documentation](https://oxc.rs/docs/guide/usage/linter/rules.html).
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion if the migration tool didn't do it automatically.
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
- Ignore files: `.eslintignore` is supported by oxlint if you have it, but it's recommended to move any ignore patterns into the `ignorePatterns` field in `.oxlintrc.json` for consistency and simplicity. All files and paths ignored via a `.gitignore` file will be ignored by oxlint by default as well.
- If you ran the migration tool multiple times, remove the `.oxlintrc.json.bak` backup file created by the migration tool once you've finished migrating.
- If you are not using any JS Plugins and have replaced your ESLint configuration, you can remove all ESLint packages from your project dependencies.
- Ensure your editor is configured to use oxlint instead of ESLint for linting and error reporting. You may want to install the Oxc extension for your preferred editor. See https://oxc.rs/docs/guide/usage/linter/editors.html for more details.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/linter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html)
- [Complete Oxlint rule list and docs](https://oxc.rs/docs/guide/usage/linter/rules.html)
......@@ -20,7 +20,7 @@ import Progress from '@/components/common/Progress/index.vue'
const locale = ref(zhCn)
onMounted(() => {
console.table(__CORE_LIB_VERSION__)
// console.table(__CORE_LIB_VERSION__)
if (import.meta.env.MODE === 'production') {
setTimeout(() => {
initWxConfig()
......
......@@ -9,6 +9,7 @@ import type {
CommentItemDto,
CommentSearchParams,
InterviewItemDto,
AtUserInfoDto,
ColumnItemDto,
VideoOptionDto,
CommentChildrenSearchParams,
......@@ -20,7 +21,7 @@ import type {
UpdateArticleRecommendAndSortDto,
} from './types'
import type { BackendServicePageResult, PageSearchParams } from '@/utils/request/types'
import { SpecificVideoRewardEnum } from '@/constants'
import { SpecificVideoRewardEnum, BooleanFlag } from '@/constants'
// 文章相关的接口(帖子 视频 实践等)
......@@ -379,3 +380,19 @@ export const topOrCancelTopComment = (commentId: number) => {
method: 'POST',
})
}
/**
* 获取可@的用户列表
*/
export const getAtUserList = (
data: PageSearchParams & {
findType?: BooleanFlag
findValue?: string
},
) => {
return service.request<BackendServicePageResult<AtUserInfoDto>>({
url: `/api/auth/getUserInfo`,
method: 'POST',
data,
})
}
......@@ -210,6 +210,7 @@ export interface ArticleItemDto {
regionHide: BooleanFlag
recommendSort: number
isOfficialAccount: BooleanFlag
deptId: string
}
/**
......@@ -336,6 +337,10 @@ export interface AddCommentDto {
content: string
pid?: number | string
imgUrl?: string
// at的人 逗号隔开
mentionUserIdList?: string
// 评论的html字符串
contentHtml?: string
}
/**
......@@ -346,6 +351,8 @@ export interface CommentItemDto {
avatar: string
children: CommentItemDto[]
content: string
/** 后端返回的富文本(@ 可点),无则回退 content */
contentHtml: string
createTime: number
hasPraise: BooleanFlag
hiddenAvatar: string
......@@ -370,6 +377,7 @@ export interface CommentItemDto {
isExpand: boolean
childNum: number
imgUrl: string
floorNumber: number
}
/**
......@@ -451,3 +459,43 @@ export interface UpdateArticleRecommendAndSortDto {
isRecommend: BooleanFlag
articleId: number
}
/**
* 可@的用户列表
*/
export interface AtUserInfoDto {
account: string
avatar: string
birthday: string
createTime: string
createUser: number
deptId: number
directLeader: number
email: string
entryDate: string
hadFansPoint: number
hiddenAvatar: string
hiddenName: string
interactiveMessageCount: number
isOfficialAccount: number
jobNumId: string
level: number
loginTime: string
name: string
officialTag: null
password: string
passwordChangeStatus: number
passwordUpdateTime: string
phone: string
region: string
regionHide: string
roleId: string
salt: string
sex: string
signature: string
status: string
updateTime: string
updateUser: number
userId: number
version: number
}
......@@ -9,6 +9,11 @@ import type {
BackendAddOrUpdateLotteryPrizeDto,
BackendAddOrUpdateLotteryConfigDto,
BackendLotteryConfigDto,
BackendWheelPrizeListSearchParams,
BackendWheelPrizeListItemDto,
BackendAddOrUpdateWheelPrizeDto,
BackendAddOrUpdateWheelConfigDto,
BackendWheelConfigDto,
} from './types'
import type { BackendServicePageResult } from '@/utils/request/types'
import { BooleanFlag } from '@/constants'
......@@ -121,3 +126,44 @@ export const setLotteryConfig = (data: BackendAddOrUpdateLotteryConfigDto) => {
data,
})
}
// ========== 大转盘抽奖 ==========
export const getWheelPrizeList = (data: BackendWheelPrizeListSearchParams) => {
return service.request<BackendServicePageResult<BackendWheelPrizeListItemDto>>({
url: '/api/cultureWheelPrizes/listPrizesByPage',
method: 'POST',
data,
})
}
export const addOrUpdateWheelPrize = (data: BackendAddOrUpdateWheelPrizeDto) => {
return service.request({
url: '/api/cultureWheelPrizes/addOrUpdatePrize',
method: 'POST',
data,
})
}
export const deleteWheelPrize = (idList: number[]) => {
return service.request({
url: '/api/cultureWheelPrizes/deletePrizes',
method: 'POST',
data: { idList },
})
}
export const getWheelConfig = () => {
return service.request<BackendWheelConfigDto>({
url: '/api/cultureWheelPrizes/getWheelConfig',
method: 'POST',
})
}
export const setWheelConfig = (data: BackendAddOrUpdateWheelConfigDto) => {
return service.request({
url: '/api/cultureWheelPrizes/setWheelConfig',
method: 'POST',
data,
})
}
......@@ -107,3 +107,42 @@ export type BackendLotteryConfigDto = {
inRegistrationTime: BooleanFlag
registrationTimeDesc: string
} | null
// ========== 大转盘抽奖 ==========
export interface BackendWheelPrizeListSearchParams extends PageSearchParams {
name?: string
isEnabled?: BooleanFlag
}
export interface BackendWheelPrizeListItemDto {
id: number
name: string
imageUrl: string
quantity: number
probability: number
isEnabled: BooleanFlag
createdAt: number
}
export interface BackendAddOrUpdateWheelPrizeDto {
id?: number
name: string
imageUrl: string
quantity: number
probability: number
isEnabled: BooleanFlag
}
export type BackendWheelConfigDto = {
isActivityActive: boolean
startTime: number
endTime: number
costYaCoin: number
} | null
export interface BackendAddOrUpdateWheelConfigDto {
costYaCoin: number
startTime: string
endTime: string
}
// 企业文化接口
export * from './task'
export * from './sign'
export * from './shop'
export * from './tag'
export * from './user'
export * from './case'
export * from './home'
export * from './practice'
export * from './common'
export * from './login'
export * from './article'
export * from './online'
export * from './otherUserPage'
export * from './auction'
export * from './dailyLottery'
export * from './launchCampaign'
export * from "./task";
export * from "./sign";
export * from "./shop";
export * from "./tag";
export * from "./user";
export * from "./case";
export * from "./home";
export * from "./practice";
export * from "./common";
export * from "./login";
export * from "./article";
export * from "./online";
export * from "./otherUserPage";
export * from "./auction";
export * from "./dailyLottery";
export * from "./launchCampaign";
export * from "./selfMessage";
export * from "./luckyWheel";
// 导出类型
export * from './task/types'
export * from './shop/types'
export * from './tag/types'
export * from './article/types'
export * from './user/types'
export * from './case/types'
export * from './home/types'
export * from './practice/types'
export * from './common/types'
export * from './login/types'
export * from './article/types'
export * from './online/types'
export * from './otherUserPage/types'
export * from './auction/types'
export * from './dailyLottery/types'
export * from './launchCampaign/types'
export * from "./task/types";
export * from "./shop/types";
export * from "./tag/types";
export * from "./article/types";
export * from "./user/types";
export * from "./case/types";
export * from "./home/types";
export * from "./practice/types";
export * from "./common/types";
export * from "./login/types";
export * from "./article/types";
export * from "./online/types";
export * from "./otherUserPage/types";
export * from "./auction/types";
export * from "./dailyLottery/types";
export * from "./launchCampaign/types";
export * from "./selfMessage/types";
export * from "./luckyWheel/types";
......@@ -31,6 +31,7 @@ export interface LoginResponseDto {
hiddenName: string
signature: string
refreshToken: string
address: string
}
export interface GetWxSignatureResponseDto {
......
import service from '@/utils/request'
import type {
WheelPrizeItemDto,
WheelConfigDto,
LuckWheelResultDto,
UserWheelRecordItemDto,
} from './types'
import type { BackendServicePageResult, PageSearchParams } from '@/utils/request/types'
// 大转盘相关接口
/**
* 获取转盘列表
*/
export const getWheelPrizeList = () => {
return service.request<WheelPrizeItemDto[]>({
url: `/api/cultureWheelPrizes/getWheelPrizes`,
method: 'POST',
data: {},
})
}
/**
* 前台相关转盘配置
*/
export const getWheelConfig = () => {
return service.request<WheelConfigDto>({
url: `/api/cultureWheelPrizes/getWheelConfig`,
method: 'POST',
data: {},
})
}
/**
* 用户抽奖
*/
export const participateLuckyWheel = () => {
return service.request<LuckWheelResultDto>({
url: `/api/cultureWheelPrizes/participate`,
method: 'POST',
data: {},
})
}
/**
* 获取用户大转盘记录
*/
export const getUserWheelRecordList = (data: PageSearchParams) => {
return service.request<BackendServicePageResult<UserWheelRecordItemDto>>({
url: `/api/cultureWheelPrizes/listWheelRecordsByPage`,
method: 'POST',
data,
})
}
import type { BooleanFlag } from '@/constants/enums'
export interface WheelPrizeItemDto {
createdAt: number
createdUser: string
description: string
id: number | null
imageUrl: string
isEnabled: BooleanFlag
name: string
probability: number
quantity: number
sortOrder: number
type: number
updatedAt: number
}
export type WheelConfigDto = {
isActivityActive: boolean
startTime: number
endTime: number
costYaCoin: number
} | null
export type LuckWheelResultDto = {
blessingText: string | null
coinCost: number
isWin: boolean
prizeId: number | null
prizeImageUrl: string
prizeName: string
}
export interface UserWheelRecordItemDto {
blessingText: string
coinCost: number
createdAt: number
createdAtStr: string
id: number
isWin: BooleanFlag
issueTime: null
issueTimeStr: string
issuerName: null
memo: string
prizeId: null
prizeImageUrl: null
prizeName: null
status: null
userId: number
userName: string
}
import service from '@/utils/request/index'
import type {
SendMessageDto,
GetMessageListDto,
ConversationResponseDto,
GetMessageDetailListDto,
MessageDetailListItem,
DeleteMessageDto,
} from './types'
// 关于私信的相关接口
/**
* 给用户发表私信
*/
export const sendMessage = (data: SendMessageDto) => {
return service.request({
url: '/api/cultureDialog/sendDialogMessage',
method: 'POST',
data,
})
}
// 获取私信列表
export const getMessageList = (data: GetMessageListDto) => {
return service.request<ConversationResponseDto>({
url: '/api/cultureDialog/getDialogList',
method: 'POST',
data,
})
}
// 查看某个私信详情
export const getMessageDetailList = (data: GetMessageDetailListDto) => {
return service.request<MessageDetailListItem[]>({
url: `/api/cultureDialog/getDialogMessageList`,
method: 'POST',
data,
})
}
// 删除某个私信
export const deleteMessage = (data: DeleteMessageDto) => {
return service.request({
url: '/api/cultureDialog/deleteMessage',
method: 'POST',
data,
})
}
import { BooleanFlag } from '@/constants'
/**
* 发送私信的参数
*/
export interface SendMessageDto {
content: string
chatType: BooleanFlag // 0 实名 1 匿名
senderId: string
receiverId: string
images: string
}
/**
* 获取私信列表的参数
*/
export interface GetMessageListDto {
chatType?: BooleanFlag
search?: string
}
/**
* 获取私信列表的响应
*/
export interface ConversationResponseDto {
anonymousUnreadCount: number
realUnreadCount: number
totalUnreadCount: number
dialogList: ConversationItem[]
}
export interface ConversationItem {
chat_type: string
chat_type_desc: string
had_read: number
id: number
is_self_last_msg: boolean
last_message: string
last_message_prefix: string
last_sender_name: string
last_time: string
last_time_str: string
last_user_id: string
other_user_account: string
other_user_avatar: string
other_user_id: string
other_user_name: string
status: number
un_read_count: number
}
/**
* 获取私信详情的列表参数
*/
export interface GetMessageDetailListDto {
receiverId: string
dialogId: number
chatType: BooleanFlag
}
/**
* 获取私信详情的列表响应
*/
export interface MessageDetailListItem {
images: string
relation_status: number
create_time: string
receiver_id: string
message_id: number
sender_name: string
sender_avatar: string
content: string
sender_id: string
relation_id: number
create_time_str: string
is_anonymous: boolean
dialog_id: number
is_self: boolean
image_list: string[]
}
/** 删除私信:会话或单条消息 */
export type DeleteCultureMessageKind = 'user' | 'message'
export interface DeleteMessageDto {
/** user:按会话删除;message:按消息删除 */
type: DeleteCultureMessageKind
/** 会话 id 列表或消息 id 列表 */
idList: number[]
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#5a4bd8"/>
<stop offset="50%" stop-color="#6858ec"/>
<stop offset="100%" stop-color="#7b6fef"/>
</linearGradient>
</defs>
<circle cx="200" cy="200" r="186" fill="none" stroke="url(#ringGrad)" stroke-width="26"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(11.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(22.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(33.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(45 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(56.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(67.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(78.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(90 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(101.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(112.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(123.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(135 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(146.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(157.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(168.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(180 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(191.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(202.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(213.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(225 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(236.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(247.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(258.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(270 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(281.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(292.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(303.75 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(315 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(326.25 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#ffd54f" transform="rotate(337.5 200 200)"/>
<circle cx="200" cy="14" r="4.5" fill="#f8bbd0" transform="rotate(348.75 200 200)"/>
</svg>
......@@ -24,6 +24,10 @@
<div class="flex items-center gap-3">
<el-skeleton-item
variant="text"
style="width: 52px; height: 28px; border-radius: 6px"
/>
<el-skeleton-item
variant="text"
style="width: 72px; height: 28px; border-radius: 6px"
/>
<el-skeleton-item
......@@ -132,7 +136,20 @@
编辑
</el-link>
<!-- 优化后的右侧内容 -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 flex-wrap justify-end">
<button
v-if="!isAudit && !isAuthor && articleDetail.createUserId"
type="button"
class="inline-flex items-center px-3.5 py-1.5 text-sm font-medium bg-blue-50 text-blue-600 rounded-lg border border-blue-100/80 shadow-sm hover:bg-blue-100 hover:border-blue-200/80 active:scale-[0.98] transition-all cursor-pointer shrink-0"
@click="
sendMessageDialogRef?.open({
receiverId: articleDetail.createUserId,
isReal: isReal,
})
"
>
私信
</button>
<span
class="px-3 py-1.5 text-sm font-medium bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100 transition-colors"
v-if="articleDetail.relateColumn"
......@@ -235,6 +252,7 @@
:initial-index="currentPreviewIndex"
@close="showPreview = false"
/>
<SendMessageDialog ref="sendMessageDialogRef" />
</div>
</template>
......@@ -243,6 +261,7 @@ import dayjs from 'dayjs'
import type { ArticleItemDto } from '@/api'
import { articleTypeListOptions, ArticleTypeEnum, VideoPositionEnum } from '@/constants'
import ActionMore from '@/components/common/ActionMore/index.vue'
import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
import type { Directive } from 'vue'
......@@ -252,13 +271,22 @@ const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const router = useRouter()
const { jumpToUserHomePage } = useNavigation()
const { articleDetail } = defineProps<{
const { articleDetail, isAudit } = defineProps<{
articleDetail: ArticleItemDto
isAudit: boolean // 是否是审核页面
}>()
const sendMessageDialogRef = ref<InstanceType<typeof SendMessageDialog> | null>(null)
const articleType = computed(() => {
return articleTypeListOptions.find((item) => item.value === articleDetail.type)?.label
})
const isReal = computed(() => {
return +(
articleDetail.type === ArticleTypeEnum.PRACTICE ||
articleDetail.type === ArticleTypeEnum.INTERVIEW ||
articleDetail.type === ArticleTypeEnum.QUESTION
)
})
const loading = computed(() => !articleDetail.title)
......
......@@ -194,6 +194,7 @@ const formattedDuration = computed(() => formatTime(videoDuration.value))
let progressTimer: ReturnType<typeof setInterval> | null = null
const SKIP_TIME = import.meta.env.MODE === 'production' ? 60 : 0
const startTimers = () => {
progressTimer = setInterval(() => {
if (videoRef.value) {
......@@ -202,7 +203,7 @@ const startTimers = () => {
videoDuration.value = duration
if (duration && Number.isFinite(duration)) {
videoProgress.value = (currentTime / duration) * 100
if (!canSkip.value && currentTime >= 60) canSkip.value = true
if (!canSkip.value && currentTime >= SKIP_TIME) canSkip.value = true
}
}
}, 200)
......
<template>
<div ref="commentRef" class="bg-white rounded-3 shadow-sm border border-gray-100 overflow-hidden">
<div ref="commentRef" class="bg-white rounded-3 shadow-sm border border-gray-100">
<!-- 评论筛选 -->
<div class="p-4 border-b border-gray-100">
<div class="flex items-center gap-4 justify-between">
......@@ -7,43 +7,65 @@
<div class="flex items-center gap-2">
<button
class="cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@click="((searchParams.sortType = 2), refresh())"
@click="((searchParams.sortType = CommentSortTypeEnum.MOST_LIKE), refresh())"
:class="{
'text-indigo-600 font-medium': searchParams.sortType === 2,
'text-gray-600 hover:text-gray-900': searchParams.sortType !== 2,
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.MOST_LIKE,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.MOST_LIKE,
}"
>
高点赞
<span
v-if="searchParams.sortType === 2"
v-if="searchParams.sortType === CommentSortTypeEnum.MOST_LIKE"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
<button
class="cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@click="((searchParams.sortType = 4), refresh())"
@click="((searchParams.sortType = CommentSortTypeEnum.MOST_COMMENT), refresh())"
:class="{
'text-indigo-600 font-medium': searchParams.sortType === 4,
'text-gray-600 hover:text-gray-900': searchParams.sortType !== 4,
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.MOST_COMMENT,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.MOST_COMMENT,
}"
>
最多点赞
最多评论
<span
v-if="searchParams.sortType === 4"
v-if="searchParams.sortType === CommentSortTypeEnum.MOST_COMMENT"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
<button
class="cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@click="((searchParams.sortType = 1), refresh())"
@click="((searchParams.sortType = CommentSortTypeEnum.EARLIEST_PUBLISH), refresh())"
:class="{
'text-indigo-600 font-medium': searchParams.sortType === 1,
'text-gray-600 hover:text-gray-900': searchParams.sortType !== 1,
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.EARLIEST_PUBLISH,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.EARLIEST_PUBLISH,
}"
>
最多评论
最早发布
<span
v-if="searchParams.sortType === CommentSortTypeEnum.EARLIEST_PUBLISH"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
<button
class="cursor-pointer px-3 py-1.5 text-sm transition-all relative"
@click="((searchParams.sortType = CommentSortTypeEnum.NEWEST_PUBLISH), refresh())"
:class="{
'text-indigo-600 font-medium':
searchParams.sortType === CommentSortTypeEnum.NEWEST_PUBLISH,
'text-gray-600 hover:text-gray-900':
searchParams.sortType !== CommentSortTypeEnum.NEWEST_PUBLISH,
}"
>
最新发布
<span
v-if="searchParams.sortType === 1"
v-if="searchParams.sortType === CommentSortTypeEnum.NEWEST_PUBLISH"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600"
></span>
</button>
......@@ -67,6 +89,7 @@
v-model:inputText="myComment"
v-model:inputImg="myCommentImgStr"
class="flex-1"
ref="myCommentBoxRef"
>
<template #submit>
<button
......@@ -112,6 +135,7 @@
<span class="font-semibold text-gray-800">{{
isReal ? item.replyUser : item.hiddenName
}}</span>
<span
v-if="item.isTop === BooleanFlag.YES"
class="inline-flex items-center gap-1 px-2 py-0.5 text-13px leading-4 font-medium text-amber-700 bg-amber-50/80 border border-amber-200/70 rounded-full"
......@@ -121,6 +145,19 @@
></span>
置顶评论
</span>
<span
v-if="!isQuestion"
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-xs leading-none text-gray-500"
>
{{ item.floorNumber || '顶' }}楼
</span>
<button
v-if="isSelfComment(item)"
class="cursor-pointer text-red-500 text-xs leading-none"
@click="handleDeleteComment(item)"
>
删除
</button>
</div>
<!-- 作者有权利置顶 并且不是问吧(问吧是获取的二级评论列表) -->
<button
......@@ -149,7 +186,8 @@
<!-- 换行 -->
<p
class="text-gray-800 my-2 break-all whitespace-pre-wrap"
v-html="parseEmoji(item.content)"
v-html="parseEmoji(item.contentHtml || item.content)"
v-parse-comment
></p>
<!-- 评论图片列表 -->
<div class="flex flex-wrap gap-2">
......@@ -218,14 +256,22 @@
child.replyUser
}}</span>
<!-- v-if="item.replyName && item.replyName !== parentComment?.replyUser" -->
<span class="text-gray-500 text-sm flex items-center gap-1">
<span class="text-gray-500 text-sm flex items-center gap-1 px-1">
<el-icon class="mx-1"><IEpCaretRight /></el-icon>
{{ child.replyName }}
</span>
<button
v-if="isSelfComment(child)"
class="cursor-pointer text-red-500 text-xs leading-none"
@click="handleDeleteComment(child)"
>
删除
</button>
</div>
<p
class="text-gray-800 my-2 break-all whitespace-pre-wrap text-[16px]"
v-html="parseEmoji(child.content)"
v-html="parseEmoji(child.contentHtml || child.content)"
v-parse-comment
></p>
<!-- 评论图片列表 -->
<div class="flex flex-wrap gap-2">
......@@ -339,7 +385,10 @@
v-model:inputText="commentToOther"
v-model:inputImg="commentToOtherImgStr"
class="flex-1"
:ref="(el) => (replyToOtherBoxRefList[index] = el as HTMLElement)"
:ref="
(ins) =>
(replyToOtherBoxRefList[index] = ins as InstanceType<typeof CommentBox>)
"
>
<template #submit>
<button
......@@ -396,8 +445,9 @@ import {
getCommentChildren,
getSecondCommentList,
topOrCancelTopComment,
deleteComment,
} from '@/api'
import { usePageSearch, useScrollTop } from '@/hooks'
import { useMessageBox, usePageSearch, useScrollTop } from '@/hooks'
import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import type { CommentItemDto } from '@/api'
import { useUserStore } from '@/stores'
......@@ -408,8 +458,10 @@ import { parseEmoji } from '@/utils/emoji'
import CommentBox from '../CommentBox/index.vue'
import dayjs from 'dayjs'
import { push } from 'notivue'
import { IS_REAL_KEY_COMMENT, CommentSortTypeEnum } from '@/constants'
const { jumpToUserHomePage } = useNavigation()
const { confirm } = useMessageBox()
const {
authorId = '',
id,
......@@ -419,7 +471,7 @@ const {
commentId = 0,
type,
} = defineProps<{
authorId?: string // 文章作者id
authorId?: string | number // 文章作者id
id: number // 文章ID
defaultSize?: number
isQuestion?: boolean // 如果是问题的话 展示有点不一样
......@@ -436,12 +488,15 @@ const total = defineModel<number>('total', { required: true, default: 0 })
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const isReal = computed(
const isReal = computed<BooleanFlag>(
() =>
type === ArticleTypeEnum.PRACTICE ||
type === ArticleTypeEnum.INTERVIEW ||
type === ArticleTypeEnum.QUESTION,
+(
type === ArticleTypeEnum.PRACTICE ||
type === ArticleTypeEnum.INTERVIEW ||
type === ArticleTypeEnum.QUESTION
),
)
provide(IS_REAL_KEY_COMMENT, isReal)
const userAvatar = computed(() => {
return isReal.value ? userInfo.value.avatar : userInfo.value.hiddenAvatar
})
......@@ -450,7 +505,7 @@ const isAuthor = computed(() => {
})
const commentRef = useTemplateRef<HTMLElement | null>('commentRef')
const commentListDialogRef = useTemplateRef<typeof CommentListDialog>('commentListDialogRef')
const replyToOtherBoxRefList = ref<HTMLElement[]>([])
const replyToOtherBoxRefList = ref<InstanceType<typeof CommentBox>[]>([])
const commentItemRefList = ref<HTMLElement[]>([])
// 回滚到评论框
const { handleBackTop } = useScrollTop(commentRef)
......@@ -469,10 +524,10 @@ const {
} = usePageSearch(isQuestion ? getSecondCommentList : getCommentList, {
defaultParams: {
...(commentId
? { pid: commentId, sortType: 2 }
? { pid: commentId, sortType: CommentSortTypeEnum.MOST_LIKE }
: {
articleId: id,
sortType: 2,
sortType: CommentSortTypeEnum.MOST_LIKE,
}),
},
defaultSize,
......@@ -517,6 +572,9 @@ const myCommentImgStr = ref('')
// 回复别人的图片
const commentToOtherImgStr = ref('')
const currentCommentId = ref(-1)
const myCommentBoxRef = useTemplateRef<InstanceType<typeof CommentBox>>('myCommentBoxRef')
const isSelfComment = (item: CommentItemDto) =>
String(item.userId || '') === String(userInfo.value.userId || '')
const handleLickComment = async (item: CommentItemDto) => {
await addOrCancelCommentLike(item.id)
......@@ -531,6 +589,18 @@ const handleLickComment = async (item: CommentItemDto) => {
}
}
const handleDeleteComment = async (item: CommentItemDto) => {
await confirm({
title: '提示',
message: '确定删除该评论吗?',
type: 'danger',
})
await deleteComment(item.id)
push.success('删除成功')
await search()
emit('commentSuccess')
}
const highlightCommentId = ref<number | null>(null)
const topCommentPendingId = ref<number | null>(null)
const handleTopComment = async (item: CommentItemDto) => {
......@@ -588,6 +658,8 @@ const handleMyComment = async () => {
content: myComment.value,
...(commentId ? { pid: commentId } : {}),
imgUrl: myCommentImgStr.value,
mentionUserIdList: myCommentBoxRef.value?.getMentionFns()?.getMentionUserIds?.()?.join?.(','),
contentHtml: myCommentBoxRef.value?.getAnswerHtml(),
})
push.success('发表评论成功')
refresh()
......@@ -610,6 +682,12 @@ const handleComment = async (index: number) => {
content: commentToOther.value,
...(currentCommentId.value ? { pid: currentCommentId.value } : {}),
imgUrl: commentToOtherImgStr.value,
mentionUserIdList:
replyToOtherBoxRefList.value[index]
?.getMentionFns?.()
?.getMentionUserIds?.()
?.join?.(',') || '',
contentHtml: replyToOtherBoxRefList.value[index]?.getAnswerHtml(),
})
push.success('发表评论成功')
commentToOther.value = ''
......
......@@ -5,13 +5,60 @@ import UploadImgIcon from '../UploadImgIcon/index.vue'
import UploadEmojiIcon from '../UploadEmojiIcon/index.vue'
import { useUploadImg } from '@/hooks'
import type { IEmoji } from '@/utils/emoji/type'
import { MENTION_USER_FN_KEY, IS_REAL_KEY_COMMENT } from '@/constants/symbolKey'
const isReal = inject(IS_REAL_KEY_COMMENT, 0)
function escapeHtml(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/** 将纯文本中的 @昵称 转为带 data-id 的 span(仅对在 MentionBox 中选中的用户生效) */
function buildAnswerHtml(source: string, mentionUsers: Array<{ userId: string; name: string }>) {
const mentions = mentionUsers
.filter((u) => u?.name)
.sort((a, b) => b.name.length - a.name.length)
.map((u) => ({ token: `@${u.name}`, userId: String(u.userId) }))
const isBoundary = (ch: string | undefined) => !ch || /\s/.test(ch)
let html = ''
let i = 0
while (i < source.length) {
const prev = i > 0 ? source[i - 1] : undefined
const matched = mentions.find(({ token }) => {
if (!source.startsWith(token, i)) return false
const next = source[i + token.length]
return isBoundary(prev) && isBoundary(next)
})
if (matched) {
html += `<span contenteditable="false" data-id="${escapeHtml(matched.userId)}" data-isreal="${unref(isReal)}" style="color: #2563eb;cursor:pointer;">${escapeHtml(matched.token)}</span>&nbsp;`
i += matched.token.length
continue
}
html += escapeHtml(source.charAt(i))
i++
}
return html
}
interface CommentBoxProps {
textAreaHeight?: number
placeholder?: string
showMention?: boolean
}
const { textAreaHeight = 55, placeholder = '请输入内容' } = defineProps<CommentBoxProps>()
const {
textAreaHeight = 55,
placeholder = '请输入内容',
showMention = true,
} = defineProps<CommentBoxProps>()
const inputStr = defineModel<string>('inputText', { required: true })
const imgStrs = defineModel<string>('inputImg', { required: true })
......@@ -37,11 +84,24 @@ const handleSelectEmoji = async (emoji: IEmoji) => {
textarea.selectionStart = textarea.selectionEnd = start + emoji.name.length
}
const mentionFnObj: {
getMentionUserIds?: () => string[]
getMentionUsers?: () => Array<{ userId: string; name: string }>
} = {}
provide(MENTION_USER_FN_KEY, mentionFnObj)
const getAnswerHtml = (): string =>
buildAnswerHtml(inputStr.value ?? '', mentionFnObj.getMentionUsers?.() ?? [])
defineExpose({
focus: async () => {
await nextTick()
richTextareaRef.value?.getTextarea()?.focus()
},
getMentionFns: () => {
return mentionFnObj
},
getAnswerHtml,
})
</script>
......@@ -55,6 +115,7 @@ defineExpose({
@deleteImg="handleDeleteImg"
:height="textAreaHeight"
:placeholder="placeholder"
:showMention="showMention"
/>
<div class="flex justify-between items-center mt-3">
<div class="flex items-center gap-2">
......
......@@ -15,6 +15,7 @@
:textAreaHeight="100"
v-model:inputText="commentStr"
v-model:inputImg="commentImgStr"
ref="commentBoxRef"
>
<template #submit>
<el-button
......@@ -37,7 +38,7 @@ import { storeToRefs } from 'pinia'
import { addComment } from '@/api'
import CommentBox from '../CommentBox/index.vue'
import { push } from 'notivue'
import { BooleanFlag, IS_REAL_KEY_COMMENT } from '@/constants'
const emit = defineEmits<{
(e: 'commentSuccess'): void
}>()
......@@ -49,10 +50,11 @@ const commentStr = ref('')
const commentImgStr = ref('')
const loading = ref(false)
const isDisabled = computed(() => !commentStr.value.trim() || loading.value)
const commentBoxRef = useTemplateRef<InstanceType<typeof CommentBox>>('commentBoxRef')
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
provide(IS_REAL_KEY_COMMENT, BooleanFlag.YES)
let articleId = 0
// 暴露 open 方法
......@@ -79,6 +81,8 @@ const handleSubmit = async () => {
articleId: articleId,
content: commentStr.value,
imgUrl: commentImgStr.value,
mentionUserIdList: commentBoxRef.value?.getMentionFns?.()?.getMentionUserIds?.()?.join?.(','),
contentHtml: commentBoxRef.value?.getAnswerHtml?.(),
})
push.success('评论发表成功')
handleClose()
......
......@@ -3,7 +3,7 @@
v-model="visible"
:title="dialogTitle"
width="650px"
class="rounded-2xl overflow-hidden"
class="rounded-2xl!"
:show-close="false"
top="5vh"
append-to-body
......@@ -27,7 +27,7 @@
<div class="flex flex-col h-[75vh]">
<!-- 中间滚动区域 -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 pt-0">
<div ref="scrollContainer" class="flex-1 overflow-y-auto custom-scrollbar p-6 pt-0">
<!-- 1. 顶部:父级评论展示 -->
<div v-if="parentComment" class="flex gap-4 bg-gray-50 p-5 rounded-xl">
<img
......@@ -36,22 +36,34 @@
/>
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<span class="font-bold text-gray-900 text-base">{{ parentComment.replyUser }}</span>
<!-- 点赞按钮 -->
<div
class="flex items-center gap-1.5 cursor-pointer text-gray-500 hover:text-blue-500 transition-colors px-3 py-1.5 rounded-full hover:bg-blue-50"
@click="handleLike(parentComment)"
>
<el-icon :size="18">
<svg-icon :name="parentComment.hasPraise ? 'praise_fill' : 'praise'"></svg-icon>
</el-icon>
<span class="text-sm font-medium">{{ parentComment.postPriseCount || 0 }}</span>
<div class="flex items-center gap-1.5">
<span class="font-bold text-gray-900 text-base">{{ parentComment.replyUser }}</span>
<button
v-if="isSelfComment(parentComment)"
class="cursor-pointer text-red-500 text-13px leading-none"
@click="handleDeleteComment(parentComment, true)"
>
删除
</button>
</div>
<div class="flex items-center gap-3">
<!-- 点赞按钮 -->
<div
class="flex items-center gap-1.5 cursor-pointer text-gray-500 hover:text-blue-500 transition-colors px-3 py-1.5 rounded-full hover:bg-blue-50"
@click="handleLike(parentComment)"
>
<el-icon :size="18">
<svg-icon :name="parentComment.hasPraise ? 'praise_fill' : 'praise'"></svg-icon>
</el-icon>
<span class="text-sm font-medium">{{ parentComment.postPriseCount || 0 }}</span>
</div>
</div>
</div>
<div
class="text-gray-800 text-base leading-relaxed mb-2"
v-html="parseEmoji(parentComment.content)"
v-html="parseEmoji(parentComment.contentHtml || parentComment.content)"
v-parse-comment="closeDialog"
></div>
<!-- 下方图片 -->
......@@ -102,6 +114,13 @@
<el-icon class="mx-1"><IEpCaretRight /></el-icon>
{{ item.replyName }}
</span>
<button
v-if="isSelfComment(item)"
class="cursor-pointer text-red-500 text-13px leading-none"
@click="handleDeleteComment(item)"
>
删除
</button>
</div>
<!-- 列表项点赞 -->
......@@ -125,8 +144,9 @@
</div>
<p
class="text-gray-700 text-base mb-2 break-all leading-relaxed"
v-html="parseEmoji(item.content)"
class="text-gray-700 text-base mb-2 break-all leading-relaxed whitespace-pre-wrap"
v-html="parseEmoji(item.contentHtml || item.content)"
v-parse-comment="closeDialog"
></p>
<!-- 下方图片 -->
<div class="flex flex-wrap gap-2" v-if="item.imgUrl">
......@@ -162,14 +182,16 @@
v-model:inputImg="imgUrl"
:textAreaHeight="60"
:placeholder="`回复 ${item.replyUser}`"
:ref="(el) => (replyToOtherBoxRefList[index] = el as HTMLElement)"
:ref="
(el) => (replyToOtherBoxRefList[index] = el as InstanceType<typeof CommentBox>)
"
>
<template #submit>
<el-button
:disabled="isDisabled"
:loading="loadingBtn"
type="primary"
@click="submitReply(item.id)"
@click="submitReply(item.id, index)"
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200"
>发表</el-button
>
......@@ -204,8 +226,9 @@
class="flex-1"
v-model:inputText="bottomCommentContent"
v-model:inputImg="bottomImgUrl"
:textAreaHeight="20"
:textAreaHeight="40"
:placeholder="`回复 ${parentComment?.replyUser}`"
ref="commentBoxRef"
/>
<el-button
......@@ -231,6 +254,7 @@ import {
addComment,
addOrCancelCommentLike,
getCommentDetail,
deleteComment,
} from '@/api'
import type { CommentItemDto } from '@/api'
import { BooleanFlag } from '@/constants'
......@@ -239,6 +263,8 @@ import { parseEmoji } from '@/utils/emoji'
import CommentBox from '../CommentBox/index.vue'
import dayjs from 'dayjs'
import { push } from 'notivue'
import { IS_REAL_KEY_COMMENT } from '@/constants/symbolKey'
import { useMessageBox } from '@/hooks'
const { articleId, pid } = defineProps<{
articleId: number
......@@ -249,10 +275,13 @@ const emit = defineEmits<{
(e: 'refresh'): void // 通知父组件刷新
}>()
provide(IS_REAL_KEY_COMMENT, BooleanFlag.YES)
// Store
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const currentUserAvatar = computed(() => userInfo.value.avatar)
const { confirm } = useMessageBox()
// State
const visible = ref(false)
......@@ -264,12 +293,16 @@ const currentInlineReplyId = ref<number | null>(null)
const bottomCommentContent = ref('')
const bottomImgUrl = ref('')
const bottomLoadingBtn = ref(false)
const replyToOtherBoxRefList = ref<HTMLElement[]>([])
const replyToOtherBoxRefList = ref<InstanceType<typeof CommentBox>[]>([])
const commentBoxRef = useTemplateRef<InstanceType<typeof CommentBox>>('commentBoxRef')
const scrollContainerRef = useTemplateRef('scrollContainer')
const commentStr = ref('')
const imgUrl = ref('')
const loadingBtn = ref(false)
const isDisabled = computed(() => !commentStr.value.trim() || loadingBtn.value)
const isSelfComment = (item: CommentItemDto) =>
String(item.userId || '') === String(userInfo.value.userId || '')
// --- Actions ---
const { list, total, search, searchParams, goToPage, changePageSize, refresh, loading } =
......@@ -318,7 +351,7 @@ const handleReplyInline = (item: CommentItemDto, index: number) => {
// 提交评论 (共用逻辑)
// targetId: 如果是回复父评论,传 parentComment.id;如果是回复子评论,传 item.id
const submitReply = async (targetId: number | undefined) => {
const submitReply = async (targetId: number | undefined, index?: number) => {
if (!targetId) return
// 判断使用的是哪个输入框的内容
......@@ -326,16 +359,27 @@ const submitReply = async (targetId: number | undefined) => {
const content = isBottom ? bottomCommentContent.value : commentStr.value
const imgStr = isBottom ? bottomImgUrl.value : imgUrl.value
try {
let mentionUserIdList: string | undefined = undefined
let contentHtml: string | undefined = undefined
if (isBottom) {
bottomLoadingBtn.value = true
mentionUserIdList = commentBoxRef.value?.getMentionFns?.()?.getMentionUserIds?.()?.join?.(',')
contentHtml = commentBoxRef.value?.getAnswerHtml?.()
} else {
loadingBtn.value = true
mentionUserIdList = replyToOtherBoxRefList.value[index!]
?.getMentionFns?.()
?.getMentionUserIds?.()
?.join?.(',')
contentHtml = replyToOtherBoxRefList.value[index!]?.getAnswerHtml?.()
}
await addComment({
articleId: articleId,
content: content,
pid: targetId, // 这里的pid逻辑根据您的后端接口来,通常回复子评论也是传该子评论ID作为pid
imgUrl: imgStr,
mentionUserIdList,
contentHtml,
})
push.success('回复成功')
......@@ -350,9 +394,15 @@ const submitReply = async (targetId: number | undefined) => {
}
// 刷新列表
refresh()
await refresh()
// 通知父组件可能需要更新评论数
emit('refresh')
scrollContainerRef.value?.scrollTo({
top: scrollContainerRef.value?.scrollHeight,
behavior: 'smooth',
})
// 下滑到底部
} catch (error) {
console.error(error)
} finally {
......@@ -383,6 +433,27 @@ const handleLike = async (item: CommentItemDto) => {
}
}
const handleDeleteComment = async (item: CommentItemDto, isParent = false) => {
await confirm({
title: '提示',
message: '确定删除该评论吗?',
type: 'danger',
})
await deleteComment(item.id)
push.success('删除成功')
if (isParent) {
visible.value = false
emit('refresh')
return
}
await refresh()
emit('refresh')
}
const closeDialog = () => {
visible.value = false
}
defineExpose({
open,
})
......
<template>
<div class="lucky-wheel-wrapper" :class="{ 'scale-80': smallerThanXl }">
<LuckyWheel
ref="myLucky"
width="260px"
height="260px"
:blocks="blocks"
:buttons="buttons"
:prizes="computedPrizes"
:default-config="defaultConfig"
:default-style="defaultStyle"
@end="endCallback"
/>
<!-- 自定义指针 -->
<div class="wheel-pointer">
<div class="pointer-pin" />
<div class="pointer-dot" />
</div>
<button
class="go-btn"
:class="{ 'is-spinning': isSpinning }"
:disabled="isSpinning"
@click="popClick"
>
<span class="go-text">GO</span>
</button>
</div>
</template>
<script lang="ts" setup>
import { LuckyWheel } from '@lucky-canvas/vue'
import ringTexture from '@/assets/img/lucky-wheel-outer-ring.svg'
import type { WheelPrizeItemDto, LuckWheelResultDto, WheelConfigDto } from '@/api'
import { participateLuckyWheel, getWheelPrizeList } from '@/api'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useMessageBox } from '@/hooks'
import { useYaBiStore } from '@/stores'
import { push } from 'notivue'
const { wheelConfig } = defineProps<{
wheelConfig: WheelConfigDto
}>()
const yabiStore = useYaBiStore()
const { confirm } = useMessageBox()
const breakpoints = useBreakpoints(breakpointsTailwind)
const smallerThanXl = breakpoints.smaller('xl')
const wheelPrizeList = ref<WheelPrizeItemDto[]>([])
const emit = defineEmits<{
handlePrizeResult: [LuckWheelResultDto]
}>()
const myLucky = ref<InstanceType<typeof LuckyWheel>>()
const defaultConfig = {
offsetDegree: 0,
speed: 20,
accelerationTime: 2500,
decelerationTime: 2500,
}
const defaultStyle = {
fontColor: '#333',
fontWeight: 'bold',
lineHeight: '14px',
lengthLimit: '60%',
}
const blocks = [
{ padding: '2px', background: '#e8e4ff' },
{
padding: '16px',
background: '#6858ec',
imgs: [
{
src: ringTexture,
width: '100%',
height: '100%',
rotate: true,
},
],
},
{ padding: '3px', background: '#7b6fef' },
]
const computedPrizes = computed(() => {
const width = wheelPrizeList.value.length >= 6 ? '37%' : '30%'
return wheelPrizeList.value.map((item: WheelPrizeItemDto, index: number) => {
return {
background: index % 2 === 0 ? '#ffffff' : '#f3f0ff',
fonts: [
{
text: item.name,
top: '8%',
fontSize: '10px',
fontColor: index % 2 === 0 ? '#6858ec' : '#e5a012',
lineClamp: 1,
},
],
imgs: [
{
src: item.imageUrl,
width,
top: '35%',
},
],
id: item.id,
}
})
})
const buttons = [
{ radius: '25%', background: '#fce4e4' },
{ radius: '20%', background: '#f75a5a' },
{
radius: '18%',
background: '#e63939',
pointer: false,
fonts: [{ text: '', top: '-14px' }],
},
]
const isSpinning = ref(false)
let resultPrize: null | LuckWheelResultDto = null
const startCallback = async () => {
if (isSpinning.value) return
isSpinning.value = true
myLucky.value?.play()
setTimeout(async () => {
const { data } = await participateLuckyWheel()
const idx = computedPrizes.value.findIndex((item) => item.id === data.prizeId)
myLucky.value?.stop(idx)
yabiStore.fetchYaBiData()
resultPrize = data
}, 1000)
}
// 获取最新的奖品列表 对比 是否更新了
const popClick = async () => {
if (isSpinning.value) return
if (yabiStore.yabiData.currentValue < wheelConfig!.costYaCoin)
return push.error(
`您的YA币不足,抽奖所需${wheelConfig!.costYaCoin}YA币,当前YA币${yabiStore.yabiData.currentValue}`,
)
const { data } = await getWheelPrizeList()
const newWheelPrizeList = data
const oldWheelPrizeList = wheelPrizeList.value
// 暂时只需要对比 1长度不一致 需要更新 2如果长度一样 如果有一组的名字或者图片 不一样 需要更新
let shouldUpdate = false
if (newWheelPrizeList.length !== oldWheelPrizeList.length) {
shouldUpdate = true
} else {
newWheelPrizeList.forEach((item: WheelPrizeItemDto, index: number) => {
if (
item.name !== oldWheelPrizeList[index]?.name ||
item.imageUrl !== oldWheelPrizeList[index]?.imageUrl
) {
shouldUpdate = true
}
})
}
if (shouldUpdate) {
// 给用户提示 奖池更新了 请重新点击
await confirm({
title: '检测到后台奖池有更新',
message: '请重新点击按钮开始抽奖',
type: 'warning',
showCancelButton: false,
})
wheelPrizeList.value = newWheelPrizeList
} else {
// 二次确认
await confirm({
title: `本次抽奖消耗${wheelConfig?.costYaCoin} YA币,确定开始吗?`,
})
startCallback()
}
}
const endCallback = () => {
setTimeout(() => {
isSpinning.value = false
emit('handlePrizeResult', resultPrize as LuckWheelResultDto)
}, 500)
}
const initWheelPrizeList = async () => {
const { data } = await getWheelPrizeList()
wheelPrizeList.value = data
}
onMounted(() => {
initWheelPrizeList()
})
</script>
<style scoped>
.lucky-wheel-wrapper {
position: relative;
display: inline-block;
width: 260px;
height: 260px;
border-radius: 50%;
box-shadow:
0 2px 8px rgba(104, 88, 236, 0.6),
0 0 16px 1px rgba(104, 88, 236, 0.08);
}
.go-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
border-radius: 50%;
border: 3px solid #ff8a80;
background: linear-gradient(145deg, #ff5252, #d32f2f);
box-shadow:
0 4px 12px rgba(211, 47, 47, 0.5),
inset 0 2px 4px rgba(255, 255, 255, 0.3);
cursor: pointer;
z-index: 10;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.go-btn:hover:not(:disabled) {
transform: translate(-50%, -50%) scale(1.1);
box-shadow:
0 6px 20px rgba(211, 47, 47, 0.6),
inset 0 2px 4px rgba(255, 255, 255, 0.4);
background: linear-gradient(145deg, #ff6e6e, #e53935);
}
.go-btn:active:not(:disabled) {
transform: translate(-50%, -50%) scale(0.92);
box-shadow:
0 2px 6px rgba(211, 47, 47, 0.4),
inset 0 3px 6px rgba(0, 0, 0, 0.15);
background: linear-gradient(145deg, #d32f2f, #c62828);
}
.go-btn:disabled {
cursor: not-allowed;
opacity: 0.8;
}
.go-btn.is-spinning {
animation: pulse-spin 1.2s ease-in-out infinite;
}
.go-text {
font-size: 22px;
font-weight: 900;
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
letter-spacing: 2px;
user-select: none;
pointer-events: none;
}
@keyframes pulse-spin {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.8;
}
50% {
transform: translate(-50%, -50%) scale(1.06);
opacity: 1;
}
}
.wheel-pointer {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.pointer-pin {
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 22px solid #ffd54f;
position: relative;
}
.pointer-pin::before {
content: '';
position: absolute;
top: -22px;
left: -8px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 18px solid #ffe082;
}
.pointer-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: linear-gradient(145deg, #ffe082, #ffc107);
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
margin-top: -3px;
}
</style>
<template>
<Comp :wheelConfig="wheelConfig" @handle-prize-result="handlePrizeResult" />
<Teleport to="body">
<Transition name="result">
<div
v-if="showResult"
class="fixed inset-0 z-[9999] bg-black/70 backdrop-blur flex flex-col items-center justify-center"
>
<template v-if="isThanksPrize">
<div ref="blessingWrapRef" class="blessing-wrap">
<div ref="blessingGlowRef" class="blessing-glow" aria-hidden="true" />
<div ref="blessingFanSceneRef" class="blessing-fan-scene">
<div ref="blessingTagSlotRef" class="blessing-tag-slot" aria-hidden="true" />
<div ref="blessingFanRef" class="blessing-fan" aria-hidden="true">
<div
v-for="(text, index) in fanStackTexts"
:key="`${text}-${index}`"
:ref="setFanItemRef"
class="blessing-fan-item"
:style="fanItemStyle(index, fanStackTexts.length)"
:data-angle="fanItemMotion(index, fanStackTexts.length).angle"
:data-x="fanItemMotion(index, fanStackTexts.length).xOffset"
:data-y="fanItemMotion(index, fanStackTexts.length).yOffset"
:data-scale="fanItemMotion(index, fanStackTexts.length).scale"
>
<div class="blessing-fan-face">
<div class="blessing-fan-top">
<span class="blessing-fan-orb" aria-hidden="true" />
<span class="blessing-fan-string" />
<!-- <span class="blessing-fan-label">今日签</span> -->
</div>
<div class="blessing-fan-meta">
<span class="blessing-fan-divider" />
<span class="blessing-fan-headline">签语</span>
<span class="blessing-fan-divider" />
</div>
<div class="blessing-fan-text">
<span class="blessing-fan-emoji">{{ fanEmoji(index) }}</span>
<span
v-for="(char, charIndex) in text.split('')"
:key="`${char}-${charIndex}`"
class="blessing-fan-char"
:class="{ 'is-gap': char === ' ' }"
>
{{ char }}
</span>
</div>
</div>
</div>
</div>
<div ref="blessingTagStageRef" class="blessing-tag-stage">
<div ref="blessingTagRef" class="blessing-tag">
<div ref="blessingTagTopRef" class="blessing-tag-top">
<span class="blessing-tag-orb" aria-hidden="true" />
<span class="blessing-tag-string" />
<span class="blessing-tag-label">今日签</span>
</div>
<div class="blessing-tag-meta">
<span class="blessing-tag-divider" />
<span class="blessing-tag-headline">签语</span>
<span class="blessing-tag-divider" />
</div>
<div class="blessing-text" :class="{ 'is-long-text': blessingChars.length > 10 }">
<span
v-for="(char, index) in blessingChars"
:key="`${char}-${index}`"
:ref="setBlessingCharRef"
:class="{ 'is-gap': char === ' ' }"
>
{{ char }}
</span>
</div>
<div ref="blessingSealRef" class="blessing-tag-seal">大吉</div>
</div>
</div>
</div>
</div>
<div ref="blessingCaptionRef" class="blessing-caption">
<p class="blessing-caption-title">今日签语</p>
<p class="blessing-caption-subtitle">抽一支好运电子签,把今天过得亮一点</p>
</div>
<el-button class="mt-8" type="primary" @click="showResult = false">知道了</el-button>
</template>
<template v-else>
<img :src="currentPrize?.prizeImageUrl" class="w-60 h-60 animate-pop" />
<div class="mt-6 text-white text-3xl font-bold">
恭喜获得 {{ currentPrize?.prizeName }}
</div>
<el-button class="mt-8" type="primary" @click="showResult = false">知道了</el-button>
</template>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue'
import gsap from 'gsap'
import Comp from './components/LuckyWheel.vue'
import type { LuckWheelResultDto, WheelConfigDto } from '@/api'
const { wheelConfig } = defineProps<{
wheelConfig: WheelConfigDto
}>()
const showResult = ref(false)
const currentPrize = ref<LuckWheelResultDto | null>(null)
const blessingWrapRef = ref<HTMLElement | null>(null)
const blessingGlowRef = ref<HTMLElement | null>(null)
const blessingFanSceneRef = ref<HTMLElement | null>(null)
const blessingFanRef = ref<HTMLElement | null>(null)
const blessingTagSlotRef = ref<HTMLElement | null>(null)
const blessingTagStageRef = ref<HTMLElement | null>(null)
const blessingTagRef = ref<HTMLElement | null>(null)
const blessingTagTopRef = ref<HTMLElement | null>(null)
const blessingSealRef = ref<HTMLElement | null>(null)
const blessingCaptionRef = ref<HTMLElement | null>(null)
const fanItemRefs = ref<HTMLElement[]>([])
const blessingCharRefs = ref<HTMLElement[]>([])
let blessingTimeline: gsap.core.Timeline | null = null
let floatTween: gsap.core.Tween | null = null
const blessingEmojiList = ['·ᴗ·', '^_^', '◕‿◕', '✦ᴗ✦', '˶ᵔ ᵕ ᵔ˶', '•‿•', '๑´ڡ`๑']
const isThanksPrize = computed(() => currentPrize.value?.prizeId == null)
const blessingChars = computed(() => {
const text = (currentPrize.value?.blessingText || '').trim()
return text ? text.split('') : ['好', '运', '常', '在']
})
const fanPreviewTexts = [
'钱包鼓鼓 烦恼全无',
'年年有鱼摸 岁岁越平安',
'干啥啥都顺 吃嘛嘛都香',
'狂吃不胖 熬夜不秃',
'凡是发生皆有利于我',
'内核强大 所向披靡',
'逆风如解意 税后十个亿',
'恶缘退散 好人靠近',
'发发发 发量爆棚',
'桃旺旺 钱财多多',
'拒绝精神内耗 本人配享太庙',
]
const fanStackTexts = computed(() => {
const texts = [...fanPreviewTexts]
const selectedText = (currentPrize.value?.blessingText || '').trim()
if (!isThanksPrize.value || !selectedText) {
return texts
}
const selectedIndex = texts.indexOf(selectedText)
if (selectedIndex === -1) {
return texts
}
const [selectedItem] = texts.splice(selectedIndex, 1)
const centerIndex = Math.floor(texts.length / 2)
texts.splice(centerIndex, 0, selectedItem as string)
return texts
})
const fanItemMotion = (index: number, total: number) => {
const center = (total - 1) / 2
const offset = index - center
const spreadRatio = center === 0 ? 0 : offset / center
const angle = spreadRatio * 30
const xOffset = offset * 40
const yOffset = Math.abs(offset) * 4
const scale = 1 - Math.abs(spreadRatio) * 0.08
return {
angle,
xOffset,
yOffset,
scale,
}
}
const fanItemStyle = (index: number, total: number) => {
const { angle, xOffset, yOffset, scale } = fanItemMotion(index, total)
return {
'--angle': `${angle}deg`,
'--x-offset': `${xOffset}px`,
'--y-offset': `${yOffset}px`,
'--scale': `${scale}`,
}
}
const fanEmoji = (index: number) => blessingEmojiList[index % blessingEmojiList.length]
const setFanItemRef = (el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
fanItemRefs.value.push(el)
}
}
const setBlessingCharRef = (el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
blessingCharRefs.value.push(el)
}
}
const clearBlessingAnimation = () => {
blessingTimeline?.kill()
blessingTimeline = null
floatTween?.kill()
floatTween = null
}
const startFloating = () => {
if (!blessingTagRef.value) {
return
}
floatTween?.kill()
floatTween = gsap.to(blessingTagRef.value, {
y: -10,
rotation: 0.8,
duration: 2.6,
repeat: -1,
yoyo: true,
ease: 'sine.inOut',
})
}
const playBlessingAnimation = () => {
const wrap = blessingWrapRef.value
const glow = blessingGlowRef.value
const scene = blessingFanSceneRef.value
const fan = blessingFanRef.value
const slot = blessingTagSlotRef.value
const tag = blessingTagRef.value
const tagTop = blessingTagTopRef.value
const seal = blessingSealRef.value
const caption = blessingCaptionRef.value
const fanItems = [...fanItemRefs.value]
const chars = [...blessingCharRefs.value]
if (
!wrap ||
!glow ||
!scene ||
!fan ||
!slot ||
!tag ||
!tagTop ||
!seal ||
!caption ||
!fanItems.length
) {
return
}
clearBlessingAnimation()
gsap.set(wrap, { transformPerspective: 1400 })
gsap.set(scene, { y: 20, opacity: 0 })
gsap.set(glow, { opacity: 0, scale: 0.78 })
gsap.set(slot, { opacity: 0, scaleX: 0.72, transformOrigin: '50% 50%' })
gsap.set(fan, { y: 34, opacity: 0 })
gsap.set(blessingTagStageRef.value, {
opacity: 0,
y: 26,
filter: 'blur(8px)',
transformOrigin: '50% 100%',
})
gsap.set(fanItems, {
opacity: 0,
y: 78,
rotate: (_index, target) => Number(target.getAttribute('data-angle') || 0) * 0.4,
scale: (_index, target) => Number(target.getAttribute('data-scale') || 1) - 0.08,
transformOrigin: '50% 100%',
})
gsap.set(tag, {
opacity: 0,
y: 356,
scale: 0.94,
rotation: 0,
transformOrigin: '50% 100%',
filter: 'blur(10px)',
})
gsap.set(tagTop, { opacity: 0.65 })
gsap.set(chars, { opacity: 0, y: 10 })
gsap.set(seal, { opacity: 0, y: 8 })
gsap.set(caption, { opacity: 0, y: 20 })
blessingTimeline = gsap.timeline({
defaults: { ease: 'power2.out' },
onComplete: () => {
startFloating()
},
})
blessingTimeline
.to(scene, { opacity: 1, y: 0, duration: 0.45 })
.to(glow, { opacity: 0.95, scale: 1, duration: 0.7, ease: 'sine.out' }, '<')
.to(slot, { opacity: 1, scaleX: 1, duration: 0.52 }, '-=0.3')
.to(fan, { opacity: 1, y: 0, duration: 0.4 }, '<')
.to(
fanItems,
{
opacity: 1,
y: (_index, target) => Number(target.getAttribute('data-y') || 0),
rotate: (_index, target) => Number(target.getAttribute('data-angle') || 0),
scale: (_index, target) => Number(target.getAttribute('data-scale') || 1),
duration: 0.9,
ease: 'back.out(1.08)',
stagger: {
each: 0.055,
from: 'center',
},
},
'+=0.02',
)
.to(
fanItems,
{
x: (_index, target) => Number(target.getAttribute('data-x') || 0) * 0.1,
duration: 0.32,
ease: 'power1.inOut',
stagger: {
each: 0.02,
from: 'edges',
},
yoyo: true,
repeat: 1,
},
'+=0.26',
)
.to(
blessingTagStageRef.value,
{
opacity: 1,
y: 0,
filter: 'blur(0px)',
duration: 0.48,
ease: 'power2.out',
},
'+=0.02',
)
.to(
tag,
{
y: 272,
opacity: 0.5,
scale: 0.97,
filter: 'blur(6px)',
duration: 0.42,
ease: 'power2.out',
},
'<',
)
.to(
tag,
{
y: 44,
opacity: 1,
scale: 1.01,
filter: 'blur(1px)',
duration: 1.08,
ease: 'power3.out',
},
'+=0.1',
)
.to(
tag,
{
y: 18,
rotation: -1.4,
scale: 1.015,
filter: 'blur(0px)',
duration: 0.3,
ease: 'power2.out',
},
'-=0.18',
)
.to(
tag,
{
y: 24,
rotation: 0.4,
scale: 1,
filter: 'blur(0px)',
duration: 0.32,
ease: 'back.out(1.5)',
},
'+=0.02',
)
.to(tagTop, { opacity: 1, duration: 0.22 }, '<')
.to(
chars,
{
opacity: 1,
y: 0,
duration: 0.34,
stagger: 0.05,
},
'-=0.08',
)
.to(
seal,
{
opacity: 1,
y: 0,
duration: 0.28,
},
'-=0.12',
)
.to(
caption,
{
opacity: 1,
y: 0,
duration: 0.42,
},
'-=0.02',
)
}
watch(
() => showResult.value,
async (visible) => {
if (!visible) {
clearBlessingAnimation()
return
}
if (!isThanksPrize.value) {
clearBlessingAnimation()
return
}
await nextTick()
playBlessingAnimation()
},
)
onBeforeUpdate(() => {
fanItemRefs.value = []
blessingCharRefs.value = []
})
onBeforeUnmount(() => {
clearBlessingAnimation()
})
const handlePrizeResult = (prize: LuckWheelResultDto) => {
currentPrize.value = prize
showResult.value = true
}
</script>
<style>
.result-enter-active {
animation: resultFadeIn 0.42s ease;
}
.result-leave-active {
animation: resultFadeOut 0.24s ease forwards;
}
@keyframes resultFadeIn {
from {
opacity: 0;
transform: scale(1.03);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes resultFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.985);
}
}
.animate-pop {
animation: pop 0.8s ease-out;
}
@keyframes pop {
0% {
transform: scale(0.3);
opacity: 0;
}
60% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.blessing-wrap {
position: relative;
width: min(92vw, 520px);
min-height: min(84vh, 680px);
display: flex;
align-items: flex-end;
justify-content: center;
isolation: isolate;
}
.blessing-glow {
position: absolute;
left: 50%;
bottom: 120px;
width: 460px;
height: 300px;
border-radius: 50%;
background: radial-gradient(
ellipse at center,
rgba(255, 247, 219, 0.32) 0%,
rgba(244, 229, 188, 0.16) 36%,
rgba(244, 229, 188, 0.06) 54%,
rgba(244, 229, 188, 0) 76%
);
transform: translateX(-50%);
filter: blur(34px);
z-index: 0;
pointer-events: none;
}
.blessing-fan-scene {
position: relative;
width: min(92vw, 520px);
height: 510px;
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1;
}
.blessing-fan {
position: absolute;
left: 50%;
bottom: 28px;
width: min(92vw, 520px);
height: 408px;
transform: translateX(-50%);
z-index: 1;
}
.blessing-tag-stage {
position: absolute;
left: 50%;
bottom: 28px;
width: 188px;
height: 550px;
transform: translateX(-50%);
z-index: 2;
}
.blessing-fan-item {
position: absolute;
left: 50%;
bottom: 0;
width: 88px;
height: 450px;
transform-origin: center 100%;
transform: translateX(calc(-50% + var(--x-offset))) translateY(var(--y-offset))
rotate(var(--angle)) scale(var(--scale));
}
.blessing-fan-face {
position: relative;
width: 100%;
height: 100%;
border-radius: 1.45rem;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.34), transparent 42%),
linear-gradient(180deg, #fffdf8 0%, #f8f0de 58%, #efe1bf 100%);
border: 1px solid rgba(216, 200, 169, 0.7);
box-shadow:
0 18px 32px rgba(58, 39, 18, 0.16),
0 8px 18px rgba(138, 105, 61, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 8px 16px;
overflow: hidden;
}
.blessing-fan-face::before {
content: '';
position: absolute;
inset: 8px;
border-radius: 1.1rem;
border: 1px solid rgba(210, 192, 157, 0.45);
pointer-events: none;
}
.blessing-fan-face::after {
content: '';
position: absolute;
left: 14px;
right: 14px;
top: 10px;
height: 56px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
filter: blur(18px);
pointer-events: none;
}
.blessing-fan-top {
position: relative;
z-index: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.blessing-fan-orb {
width: 8px;
height: 8px;
border-radius: 50%;
background: #c14a3f;
box-shadow: 0 0 0 3px rgba(193, 74, 63, 0.08);
}
.blessing-fan-string {
width: 1.5px;
height: 16px;
margin-top: 3px;
border-radius: 999px;
background: linear-gradient(180deg, #c14a3f, rgba(193, 74, 63, 0.1));
}
.blessing-fan-label {
margin-top: 6px;
color: #9d8460;
font-size: 8px;
font-weight: 800;
letter-spacing: 0.22em;
}
.blessing-fan-meta {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
margin-top: 10px;
color: #b29467;
}
.blessing-fan-headline {
font-size: 8px;
font-weight: 500;
letter-spacing: 0.08em;
}
.blessing-fan-divider {
width: 12px;
height: 1px;
background: rgba(204, 181, 141, 0.55);
}
.blessing-fan-text {
position: relative;
z-index: 1;
margin-top: 16px;
flex: 1 1 auto;
width: 100%;
padding: 10px 4px 18px;
color: #503821;
font-size: 17px;
font-weight: 600;
letter-spacing: 0.08em;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 6px;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
text-shadow: 0 1px 0 rgba(255, 251, 224, 0.2);
}
.blessing-fan-emoji {
margin-bottom: 12px;
color: rgba(104, 69, 0, 0.82);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1;
}
.blessing-fan-char {
position: relative;
line-height: 1;
}
.blessing-fan-char.is-gap {
margin: 10px 0;
opacity: 0;
}
.blessing-fan-char::after {
content: '';
position: absolute;
left: 50%;
bottom: -2px;
width: 2px;
height: 2px;
border-radius: 50%;
background: rgba(167, 119, 7, 0.22);
transform: translateX(-50%);
}
.blessing-fan-char:last-child::after {
display: none;
}
.blessing-fan-char.is-gap::after {
display: none;
}
.blessing-tag {
position: absolute;
left: 50%;
bottom: 0;
width: 7.5rem;
min-height: 32rem;
padding: 20px 14px 26px;
border-radius: 2.2rem;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.34), transparent 42%),
linear-gradient(180deg, #fffdf8 0%, #f8f0de 58%, #efe1bf 100%);
border: 1px solid rgba(216, 200, 169, 0.7);
box-shadow:
0 24px 60px rgba(58, 39, 18, 0.16),
0 10px 24px rgba(138, 105, 61, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
transform: translateX(-50%);
z-index: 3;
}
.blessing-tag::before {
content: '';
position: absolute;
inset: 11px;
border-radius: 1.75rem;
border: 1px solid rgba(210, 192, 157, 0.45);
pointer-events: none;
}
.blessing-tag::after {
content: '';
position: absolute;
left: 20px;
right: 20px;
top: 14px;
height: 80px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
filter: blur(24px);
pointer-events: none;
}
.blessing-tag-stage::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.16;
mix-blend-mode: multiply;
background-image: radial-gradient(rgba(146, 118, 72, 0.28) 0.6px, transparent 0.6px);
background-size: 7px 7px;
pointer-events: none;
}
.blessing-tag-top {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
}
.blessing-tag-orb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #c14a3f;
box-shadow: 0 0 0 4px rgba(193, 74, 63, 0.08);
}
.blessing-tag-string {
width: 2px;
height: 26px;
margin-top: 4px;
border-radius: 999px;
background: linear-gradient(180deg, #c14a3f, rgba(193, 74, 63, 0.1));
}
.blessing-tag-label {
margin-top: 8px;
color: #9d8460;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.42em;
}
.blessing-tag-meta {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
color: #b29467;
font-size: 12px;
letter-spacing: 0.12em;
}
.blessing-tag-headline {
color: #b29467;
font-size: 11px;
font-weight: 500;
}
.blessing-tag-divider {
width: 24px;
height: 1px;
background: rgba(204, 181, 141, 0.55);
}
.blessing-text {
position: relative;
z-index: 1;
margin: 16px 0;
width: 100%;
flex: 1;
justify-content: center;
color: #503821;
font-size: 24px;
font-weight: 700;
letter-spacing: 0.14em;
line-height: 1.26;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 20px 8px;
border-radius: 1.6rem;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
writing-mode: vertical-rl;
text-orientation: mixed;
}
.blessing-text.is-long-text {
font-size: 21px;
}
.blessing-text > span {
display: inline-block;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.24);
}
.blessing-text > span.is-gap {
margin: 12px 0;
opacity: 0;
}
.blessing-tag-seal {
position: relative;
z-index: 1;
margin-top: 18px;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid rgba(199, 93, 79, 0.2);
background: rgba(185, 65, 53, 0.1);
color: #b33f34;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.28em;
box-shadow: 0 4px 10px rgba(185, 65, 53, 0.08);
}
.blessing-caption {
margin-top: 34px;
text-align: center;
}
.blessing-caption-title {
margin: 0;
color: rgba(255, 255, 255, 0.96);
font-size: 34px;
font-weight: 700;
letter-spacing: 3px;
}
.blessing-caption-subtitle {
margin: 12px 0 0;
color: rgba(255, 255, 255, 0.72);
font-size: 15px;
letter-spacing: 1px;
}
@media (max-width: 640px) {
.blessing-wrap {
width: min(94vw, 390px);
min-height: 560px;
}
.blessing-glow {
width: 320px;
height: 220px;
bottom: 112px;
}
.blessing-fan-scene {
width: min(94vw, 390px);
height: 434px;
}
.blessing-fan {
width: min(94vw, 390px);
height: 350px;
bottom: 24px;
}
.blessing-fan-item {
width: 74px;
height: 304px;
}
.blessing-fan-face {
padding: 12px 6px 12px;
}
.blessing-tag-stage {
width: 160px;
height: 484px;
bottom: 24px;
}
.blessing-tag {
width: 9.6rem;
min-height: 27rem;
padding: 18px 12px 22px;
}
.blessing-fan-text {
margin-top: 12px;
padding: 14px 2px;
font-size: 13px;
gap: 6px;
}
.blessing-fan-emoji {
margin-bottom: 8px;
font-size: 14px;
}
.blessing-fan-char.is-gap {
margin: 8px 0;
}
.blessing-fan-label,
.blessing-fan-headline {
font-size: 7px;
}
.blessing-text {
font-size: 20px;
padding: 18px 6px;
}
.blessing-text.is-long-text {
font-size: 18px;
}
.blessing-text > span.is-gap {
margin: 10px 0;
}
.blessing-caption-title {
font-size: 28px;
}
}
</style>
<script lang="tsx">
import {
type VNode,
cloneVNode,
inject,
nextTick,
ref,
render,
} from 'vue'
import getCaretCoordinates from 'textarea-caret'
import MentionList from '../MentionList/index.vue'
import type { AtUserInfoDto } from '@/api/article/types'
import { MENTION_USER_FN_KEY, IS_REAL_KEY_COMMENT } from '@/constants/symbolKey'
type SlotModelAccessor = {
getValue: () => string
setValue: (v: string) => void
}
const extractModelAccessor = (vnode: VNode): SlotModelAccessor | null => {
const props = vnode.props
if (!props) return null
const updater = props['onUpdate:modelValue'] as ((v: string) => void) | undefined
if (!updater) return null
const dirs = (vnode as any).dirs as { dir: any; value: any }[] | undefined
const modelDir = dirs?.find((d) => d.dir?.mounted && d.dir?.beforeUpdate)
return {
getValue: () => (modelDir?.value as string) ?? (props.value as string) ?? '',
setValue: (v: string) => updater(v),
}
}
export default {
name: 'MentionBox',
setup(_, { slots }) {
const isReal = inject(IS_REAL_KEY_COMMENT)!
const renderBoxRef = useTemplateRef<HTMLDivElement>('renderBoxRef')
let cursorPos = 0
let accessor: SlotModelAccessor | null = null
const mentionUsers = ref<Array<{ userId: string; name: string }>>([])
const mousePosition = ref({ x: 0, y: 0 })
const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const existsMentionToken = (text: string, name: string) => {
const pattern = new RegExp(`(^|\\s)@${escapeRegExp(name)}(?=\\s|$)`)
return pattern.test(text)
}
const syncMentionUsersWithText = (text: string) => {
mentionUsers.value = mentionUsers.value.filter((u) => existsMentionToken(text, u.name))
}
const updateMentionPanelPosition = (textarea: HTMLTextAreaElement, caretIndex: number) => {
const { left, top } = getCaretCoordinates(textarea, caretIndex)
mousePosition.value = { x: left - 18, y: top + 30 }
}
/** 光标是否在「可弹出选人」的 @ 片段末尾(@ 后可为空或正在输入关键词) */
const shouldOpenMentionAtCursor = (beforeCursor: string) => {
if (!/@([^\s@]*)$/.test(beforeCursor)) return false
const atIndex = beforeCursor.lastIndexOf('@')
if (atIndex < 0) return false
if (atIndex === 0) return true
const prev = beforeCursor.charAt(atIndex - 1)
if (prev === '@') return false
if (prev && /\s/.test(prev)) return true
// 非空白、非连续 @ 的前缀(如 12@、ca1@)也应能触发,旧版 (^|\\s)@ 会漏掉
return true
}
const showMentionList = () => {
render(
<MentionList mousePosition={mousePosition.value} onMention={handleMention} isReal={unref(isReal)} />,
renderBoxRef.value as HTMLElement,
)
}
const handleInput = (e: Event) => {
const textarea = e.target as HTMLTextAreaElement
cursorPos = textarea.selectionStart
syncMentionUsersWithText(textarea.value)
const beforeCursor = textarea.value.slice(0, cursorPos)
if (shouldOpenMentionAtCursor(beforeCursor)) {
updateMentionPanelPosition(textarea, cursorPos)
showMentionList()
}
}
const findMentionRangeByIndex = (text: string, index: number) => {
if (index < 0 || index >= text.length) return null
for (const u of mentionUsers.value) {
const token = `@${u.name}`
let start = text.indexOf(token)
while (start !== -1) {
const end = start + token.length
const prev = start > 0 ? text.charAt(start - 1) : ''
const next = end < text.length ? text.charAt(end) : ''
const boundaryOk = (!prev || /\s/.test(prev)) && (!next || /\s/.test(next))
if (boundaryOk && index >= start && index < end) {
return { start, end }
}
start = text.indexOf(token, start + token.length)
}
}
return null
}
const handleKeydown = (e: KeyboardEvent) => {
if (!accessor) return
if (e.key !== 'Backspace' && e.key !== 'Delete') return
const textarea = e.target as HTMLTextAreaElement
const value = accessor.getValue()
const caret = textarea.selectionStart
const targetIndex = e.key === 'Backspace' ? caret - 1 : caret
const mentionRange = findMentionRangeByIndex(value, targetIndex)
if (!mentionRange) return
e.preventDefault()
const nextValue = value.slice(0, mentionRange.start) + value.slice(mentionRange.end)
accessor.setValue(nextValue)
syncMentionUsersWithText(nextValue)
nextTick(() => {
const nextCaret = mentionRange.start
textarea.setSelectionRange(nextCaret, nextCaret)
cursorPos = nextCaret
})
}
const handleMention = (item: AtUserInfoDto) => {
if (!accessor) return
const value = accessor.getValue()
const beforeAt = value.slice(0, cursorPos).replace(/@([^\s@]*)$/, '')
const after = value.slice(cursorPos)
const nextValue = `${beforeAt}@${item.name} ${after}`
accessor.setValue(nextValue)
if (!mentionUsers.value.some((u) => String(u.userId) === String(item.userId))) {
mentionUsers.value.push({
userId: String(item.userId),
name: item.name,
})
}
syncMentionUsersWithText(nextValue)
}
const mentionFnObj = inject(MENTION_USER_FN_KEY)
if (mentionFnObj) {
mentionFnObj.getMentionUserIds = () => mentionUsers.value.map((u) => u.userId)
mentionFnObj.getMentionUsers = () => mentionUsers.value.map((u) => ({ ...u }))
}
return () => {
const raw = slots.default?.()[0] as VNode
if (!raw) return null
accessor = extractModelAccessor(raw)
const wrapped = cloneVNode(raw, { onInput: handleInput, onKeydown: handleKeydown })
return <div ref='renderBoxRef' class="relative">{wrapped}</div>
}
},
}
</script>
<style scoped lang="scss"></style>
<script setup lang="ts">
import { getAtUserList } from '@/api'
import { usePageSearch } from '@/hooks'
import type { AtUserInfoDto } from '@/api/article/types'
import { BooleanFlag } from '@/constants'
const show = defineModel<boolean>()
const { mousePosition = { x: 10, y: 10 }, isReal } = defineProps<{
mousePosition: {
x: number
y: number
}
isReal: BooleanFlag
}>()
const emit = defineEmits<{
mention: [item: AtUserInfoDto]
}>()
const { list, searchParams, total, goToPage, changePageSize, loading } = usePageSearch(
getAtUserList,
{
defaultParams: {
current: 1,
size: 10,
findType: isReal === BooleanFlag.YES ? 0 : 1,
},
},
)
// const list = [
// {
// id: 1,
// name: '李家彬',
// dept: '市场部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=li',
// },
// {
// id: 2,
// name: '王小雨',
// dept: '产品部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=wang',
// },
// {
// id: 3,
// name: '赵一鸣',
// dept: '技术部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=zhao',
// },
// {
// id: 4,
// name: '刘可可',
// dept: '运营部',
// avatar: 'https://api.dicebear.com/7.x/adventurer/svg?seed=liu',
// },
// ]
watch(show, (visible) => {
if (visible) {
document.addEventListener('mousedown', handleDocumentPointerDown)
} else {
document.removeEventListener('mousedown', handleDocumentPointerDown)
}
})
const handleDocumentPointerDown = () => {
show.value = false
}
const handlePick = (item: AtUserInfoDto) => {
show.value = false
emit('mention', item)
}
onUpdated(() => {
show.value = true
})
onMounted(() => {
show.value = true
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleDocumentPointerDown)
})
defineExpose({
show: () => {
show.value = true
},
hide: () => {
show.value = false
},
})
</script>
<template>
<transition name="fade1">
<div
v-if="show"
class="absolute mention-panel z-[3000] w-60 rounded-xl border border-slate-200 bg-white p-2 shadow-[0_10px_26px_rgba(2,6,23,0.15)] transition-all duration-200"
:style="{ left: mousePosition.x + 'px', top: mousePosition.y + 'px' }"
@mousedown.stop
>
<div class="mb-1 flex items-center gap-1 border-b border-slate-100 pb-2">
<el-input
v-model="searchParams.findValue"
placeholder="搜索用户名"
size="small"
clearable
class="min-w-0 flex-1"
@keyup.enter="goToPage(1)"
/>
<el-button type="primary" size="small" :disabled="loading" @click="goToPage(1)">
搜索
</el-button>
<el-pagination
v-model:current-page="searchParams.current"
v-model:page-size="searchParams.size"
:total="total"
size="small"
class="shrink-0 !p-0"
layout="prev, slot, next"
@size-change="changePageSize"
@current-change="goToPage"
>
<span class="text-xs text-slate-400">{{ searchParams.current }}</span>
</el-pagination>
</div>
<el-scrollbar class="h-64" v-loading="loading">
<button
v-for="item in list"
:key="item.userId"
type="button"
class="cursor-pointer flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-blue-50"
@click="handlePick(item)"
>
<img
:src="item.avatar"
:alt="item.name"
class="h-8 w-8 rounded-full border border-slate-200"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-slate-800">{{ item.name }}</div>
</div>
<span v-if="isReal" class="text-xs text-blue-500">@{{ item.name }}</span>
</button>
</el-scrollbar>
</div>
</transition>
</template>
<style scoped>
.mention-panel::before {
position: absolute;
top: -6px;
left: 16px;
width: 12px;
height: 12px;
border-top: 1px solid #e2e8f0;
border-left: 1px solid #e2e8f0;
background: #fff;
content: '';
transform: rotate(45deg);
}
.fade1-enter-active,
.fade1-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.fade1-enter-from,
.fade1-leave-to {
opacity: 0;
transform: translateY(6px);
}
</style>
......@@ -66,7 +66,10 @@
</p>
<!-- Actions -->
<div class="mt-5 grid grid-cols-2 gap-2.5">
<div
class="mt-5 grid grid-cols-2 gap-2.5"
:class="{ 'grid-cols-1!': !showCancelButton || !showConfirmButton }"
>
<button
v-if="showCancelButton"
type="button"
......
......@@ -52,6 +52,7 @@
<!-- 主要内容输入 -->
<div class="relative mb-3">
<RichTextarea
:showMention="false"
:placeholder="textMap[type].content"
:maxlength="maxLength"
:imgList="imgList"
......
<script setup lang="ts">
// 展示一个textarea 里面可展示图片等(暂时只加入了 图片 后续若有其他的 再添加)
// 暂时用到了快捷发布问吧 和 发布实践 以及 评论相关的内容
import MentionBox from '../MentionBox/index.vue'
import { MENTION_USER_FN_KEY } from '@/constants/symbolKey'
// console.log(VueTribute, 'VueTribute')
interface RichTextareaProps {
placeholder?: string
maxlength?: number
imgList: string[]
uploadPercent: number
height?: number
showMention?: boolean
}
interface Emits {
deleteImg: [img: string]
......@@ -19,11 +22,72 @@ const {
imgList,
uploadPercent,
height = 55,
showMention = true,
} = defineProps<RichTextareaProps>()
const emit = defineEmits<Emits>()
const inputStr = defineModel<string>({ required: true })
const textareaRef = useTemplateRef<HTMLTextAreaElement>('textareaRef')
const highlightRef = useTemplateRef<HTMLElement>('highlightRef')
const isComposing = ref(false)
const mentionFns = inject<{
getMentionUsers?: () => Array<{ userId: string; name: string }>
}>(MENTION_USER_FN_KEY, {})
const escapeHtml = (str: string) =>
str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const highlightedHtml = computed(() => {
const source = inputStr.value || ''
const mentions = (mentionFns?.getMentionUsers?.() ?? [])
.filter((u) => u?.name)
.sort((a, b) => b.name.length - a.name.length)
.map((u) => `@${u.name}`)
const isBoundary = (ch: string | undefined) => !ch || /\s/.test(ch)
let html = ''
let i = 0
while (i < source.length) {
const prev = i > 0 ? source[i - 1] : undefined
const token = mentions.find((item) => {
if (!source.startsWith(item, i)) return false
const next = source[i + item.length]
return isBoundary(prev) && isBoundary(next)
})
if (token) {
html += `<span class="mention-token">${escapeHtml(token)}</span>`
i += token.length
continue
}
html += escapeHtml(source.charAt(i))
i++
}
return html
})
const syncHighlightScroll = (e: Event) => {
const textarea = e.target as HTMLTextAreaElement
if (!highlightRef.value) return
highlightRef.value.scrollTop = textarea.scrollTop
highlightRef.value.scrollLeft = textarea.scrollLeft
}
const handleCompositionStart = () => {
isComposing.value = true
}
const handleCompositionEnd = () => {
isComposing.value = false
}
defineExpose({
getTextarea: () => textareaRef.value,
})
......@@ -34,15 +98,44 @@ defineExpose({
style="border: 1px solid rgb(229, 231, 235)"
class="relative w-full rounded-lg border border-gray-200 px-3 py-2 transition focus-within:border-[var(--el-color-primary)] focus-within:ring-1 focus-within:ring-[var(--el-color-primary)] bg-white"
>
<!-- 文本输入区 -->
<textarea
ref="textareaRef"
v-model="inputStr"
:placeholder="placeholder"
class="w-full resize-none border-none outline-none text-sm leading-5 text-gray-800"
:style="{ height: height + 'px' }"
:maxlength="maxlength"
/>
<div class="relative">
<template v-if="showMention">
<div
ref="highlightRef"
aria-hidden="true"
class="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5"
:style="{ height: height + 'px' }"
:class="{ invisible: isComposing }"
>
<div class="w-full text-gray-800" v-html="highlightedHtml || '&nbsp;'"></div>
</div>
<MentionBox>
<textarea
ref="textareaRef"
v-model="inputStr"
:placeholder="placeholder"
class="w-full resize-none border-none bg-transparent outline-none text-sm leading-5 placeholder:text-gray-400 caret-gray-800"
:class="isComposing ? 'text-gray-800' : 'text-transparent'"
:style="{ height: height + 'px' }"
:maxlength="maxlength"
@scroll="syncHighlightScroll"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</MentionBox>
</template>
<template v-else>
<!-- 无镜像高亮层时不能 text-transparent,否则正文不可见 -->
<textarea
ref="textareaRef"
v-model="inputStr"
:placeholder="placeholder"
class="w-full resize-none border-none bg-transparent outline-none text-sm leading-5 text-gray-800 placeholder:text-gray-400 caret-gray-800"
:style="{ height: height + 'px' }"
:maxlength="maxlength"
/>
</template>
</div>
<!-- 定位到右边 -->
<span v-if="maxlength" class="flex justify-end text-xs text-gray-400">
{{ inputStr?.length }} / {{ maxlength }}
......@@ -79,7 +172,14 @@ defineExpose({
</div>
</div>
</div>
<!-- <MentionList ref="mentionListRef" :mouse-position="mousePosition" @mention="handleMention" /> -->
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
:deep(.mention-token) {
color: #2563eb;
background: rgba(37, 99, 235, 0.1);
border-radius: 2px;
}
</style>
<template>
<el-dialog
v-model="visible"
:title="form.chatType === BooleanFlag.YES ? '发送匿名私信' : '发送实名私信'"
width="500px"
:before-close="handleClose"
top="30vh"
class="send-message-dialog"
destroy-on-close
>
<div class="flex gap-3">
<el-avatar :size="40" :src="avatar" />
<CommentBox
class="flex-1"
:textAreaHeight="100"
placeholder="输入私信内容…"
v-model:inputText="form.content"
v-model:inputImg="form.images"
:showMention="false"
>
<template #submit>
<el-button
:disabled="isDisabled"
:loading="loading"
type="primary"
round
@click="handleSubmit"
class="px-5 !rounded-lg text-sm font-medium shadow-sm"
>发送</el-button
>
</template>
</CommentBox>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { sendMessage } from '@/api'
import CommentBox from '../CommentBox/index.vue'
import { push } from 'notivue'
import { BooleanFlag } from '@/constants'
import type { SendMessageDto } from '@/api'
import { useResetData } from '@/hooks'
const emit = defineEmits<{
(e: 'sendSuccess'): void
}>()
const visible = ref(false)
const loading = ref(false)
const isDisabled = computed(() => {
return !form.value.content || loading.value
})
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const [form] = useResetData<SendMessageDto>({
content: '',
chatType: BooleanFlag.NO,
senderId: userInfo.value.userId,
receiverId: '',
images: '',
})
const avatar = computed(() => {
return form.value.chatType === BooleanFlag.YES
? userInfo.value.hiddenAvatar
: userInfo.value.avatar
})
const open = ({ receiverId, isReal }: { receiverId: string; isReal: BooleanFlag }) => {
visible.value = true
form.value.receiverId = receiverId
form.value.chatType = isReal ? BooleanFlag.NO : BooleanFlag.YES
}
const handleClose = () => {
visible.value = false
form.value.content = ''
form.value.images = ''
}
const handleSubmit = async () => {
loading.value = true
try {
await sendMessage(form.value)
push.success('发送成功')
handleClose()
emit('sendSuccess')
} catch (error) {
console.log(error)
} finally {
loading.value = false
}
}
defineExpose({
open,
})
</script>
<style scoped>
.send-message-dialog :deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
}
</style>
......@@ -17,6 +17,7 @@ export const app_config: { [key: string]: IConfig } = {
// 测试环境 暂时无测试环境部署
test: {
// baseUrl: 'http://culture.yswg.com.cn:8089', // 线上测试机
baseUrl: 'http://192.168.2.55:8089', // 首拥本地
loginType: 1,
wxRedirect: '',
......
......@@ -112,6 +112,8 @@ export enum ActivityTypeEnum {
AUCTION = 1,
// 每日抽奖
DAILY_LOTTERY = 2,
// 大转盘
WHEEL = 3,
}
// 竞拍状态枚举
......@@ -150,6 +152,8 @@ export enum GoodsDistributionTypeEnum {
ORDER = 'order',
// 竞拍
AUCTION = 'auction',
// 大转盘
WHEEL = 'wheel',
}
// 特定视频奖励枚举
......@@ -163,3 +167,15 @@ export enum SpecificVideoRewardEnum {
// 创新碎片
INNOVATION_CHIP = 'INNOVATION_CHIP',
}
// 评论排序类型枚举
export enum CommentSortTypeEnum {
// 最多评论
MOST_COMMENT = 1,
// 最新发布
NEWEST_PUBLISH = 2,
// 最早发布
EARLIEST_PUBLISH = 3,
// 最高点赞
MOST_LIKE = 4,
}
......@@ -233,6 +233,10 @@ export const goodsDistributionTypeListOptions: {
label: '竞拍',
value: GoodsDistributionTypeEnum.AUCTION,
},
{
label: '大转盘',
value: GoodsDistributionTypeEnum.WHEEL,
},
]
// 特定视频奖励列表
......@@ -263,3 +267,21 @@ export const specificVideoRewardListOptions: {
title: '从诗词歌赋聊到人生哲学',
},
]
export const firstDepListOptions = [
{ id: '1825', name: 'Amazon产品开发部' },
{ id: '76', name: 'Amazon销售部' },
{ id: '139', name: 'Amazon运营部' },
{ id: '5114', name: 'Temu销售部' },
{ id: '4491', name: 'Tiktok项目部' },
{ id: '39', name: '视觉设计部' },
{ id: '23', name: '知识产权部' },
{ id: '499', name: '供应链管理部' },
{ id: '20', name: 'IT技术部' },
{ id: '5', name: '财务部' },
{ id: '2357', name: '人力发展中心' },
{ id: '683', name: '副总经理' },
{ id: '6', name: '总经理' },
{ id: '3', name: '董事长' },
]
\ No newline at end of file
import type { InjectionKey, Ref } from 'vue'
import type { InjectionKey, Ref, MaybeRef } from 'vue'
import type { BooleanFlag } from './enums'
export const TABS_REF_KEY = Symbol('tabsRef') as InjectionKey<Ref<HTMLElement | null>>
export const COMMENT_REF_KEY = Symbol('commentRef') as InjectionKey<Ref<HTMLElement | null>>
export const IS_REAL_KEY = Symbol('isReal') as InjectionKey<Ref<number>>
// 获取at用户相关函数的key
export const MENTION_USER_FN_KEY = Symbol('mentionUserFn') as InjectionKey<{
getMentionUserIds?: () => string[]
getMentionUsers?: () => Array<{ userId: string; name: string }>
}>
// 是否是实名 评论相关
export const IS_REAL_KEY_COMMENT = Symbol('isRealComment') as InjectionKey<MaybeRef<BooleanFlag>>
export * from './vCommentParse'
import type { Directive } from 'vue'
import router from '@/router'
// 将评论内容中at 添加点击事件
export const vParseComment: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value: onClickedCallback } = binding
el.querySelectorAll('span[data-id]').forEach((item) => {
item.addEventListener('click', () => {
const id = (item as HTMLElement).dataset.id
const isReal = (item as HTMLElement).dataset.isreal
if (id && isReal) {
router.push(`/otherUserPage/${id}/${isReal}`)
onClickedCallback?.()
}
})
})
},
}
......@@ -56,6 +56,7 @@
</RewardButton>
<div
v-if="!isWareHouse"
class="group flex items-center cursor-pointer px-2 py-1 sm:px-3 sm:py-2 rounded-lg transition-all duration-200 hover:shadow-lg hover:bg-white/60"
@click="router.push('/auction')"
>
......@@ -175,6 +176,7 @@ const userStore = useUserStore()
const activityStore = useActivityStore()
const { userInfo } = storeToRefs(userStore)
const isWareHouse = userInfo.value.address.includes('仓库')
const router = useRouter()
const route = useRoute()
......@@ -217,7 +219,6 @@ const isDropdownHover = ref(false)
// 首页触发CG相关引导
onMounted(() => {
console.log(1222)
activityStore.triggerCgGuide()
})
</script>
......
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import LuckyCanvas from '@lucky-canvas/vue'
import './style/index.css'
......@@ -16,6 +16,8 @@ import { createNotivue } from 'notivue'
import 'notivue/notification.css'
import 'notivue/animations.css'
import { vParseComment } from '@/directives'
if (import.meta.env.MODE === 'production') {
import('@/utils/version').then(({ loopGetVersion }) => loopGetVersion())
}
......@@ -32,7 +34,10 @@ const app = createApp(App)
app.use(notivue)
app.use(createPinia())
app.use(router)
app.use(LuckyCanvas)
// 全局指令挂载
app.directive('parse-comment', vParseComment)
// 全局组件挂载
app.component('SvgIcon', SvgIcon)
app.mount('#app')
......@@ -108,7 +108,7 @@ export const constantsRoute = [
},
],
},
// 点进去其他用户的个人中心
// 点进去其他用户的个人中心 这里面有bug
{
path: 'otherUserPage/:userId/:isReal',
name: 'CultureOtherUserPage',
......@@ -189,6 +189,12 @@ export const constantsRoute = [
name: 'Auction',
component: () => import('@/views/auction/index.vue'),
},
// 我的私信
{
path: 'selfMessage',
name: 'CultureSelfMessage',
component: () => import('@/views/selfMessage/index.vue'),
},
],
},
......@@ -198,12 +204,6 @@ export const constantsRoute = [
component: () => import('@/views/backend/index.vue'),
redirect: '/backend/tags',
children: [
// {
// path: 'manager',
// name: 'ManagerManagement',
// component: () => import('@/views/backend/manager/index.vue'),
// meta: { title: '企业文化管理员' },
// },
{
path: 'tags',
name: 'OfficialManagement',
......@@ -216,24 +216,6 @@ export const constantsRoute = [
component: () => import('@/views/backend/carousel/index.vue'),
meta: { title: '轮播图设置' },
},
// {
// path: 'topic-admin',
// name: 'TopicAdminManagement',
// component: () => import('@/views/backend/topic-admin/index.vue'),
// meta: { title: '专栏——管理员' },
// },
// {
// path: 'interview-admin',
// name: 'InterviewAdminManagement',
// component: () => import('@/views/backend/interview-admin/index.vue'),
// meta: { title: '专访——管理员' },
// },
// {
// path: 'videoManage',
// name: 'VideoManageManagement',
// component: () => import('@/views/backend/videoManage/index.vue'),
// meta: { title: '视频管理' },
// },
{
path: 'caseManage',
name: 'CaseManageManagement',
......@@ -302,6 +284,13 @@ export const constantsRoute = [
component: () => import('@/views/backend/settingsMenu/dailyLotteryManage/index.vue'),
meta: { title: '每日抽奖配置' },
},
// 转盘抽奖配置
{
path: 'settingsMenu/luckyWheelManage',
name: 'LuckyWheelManageManagement',
component: () => import('@/views/backend/settingsMenu/luckyWheelManage/index.vue'),
meta: { title: '转盘抽奖配置' },
},
/**
* 栏目管理下的子菜单
*/
......
......@@ -42,7 +42,6 @@ export const useUserStore = defineStore('user', () => {
cutEmail?: string
}) => {
const { data } = await loginByCode({ code, isCodeLogin, cutEmail })
console.log(data)
setUserInfoAndToken(data)
// 同时获取关于分片的token
......
......@@ -9,21 +9,21 @@ type EmojiAsset = {
const emojiList: EmojiAsset[] = [...emojis, ...yayas]
function escapeHTML(str: string) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// function escapeHTML(str: string) {
// return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// }
export const parseEmoji = (content: string) => {
if (!content) return ''
let html = escapeHTML(content)
// let html = escapeHTML(content)
let html = content
emojiList.forEach((item) => {
const escapedName = item.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const reg = new RegExp(escapedName, 'g')
console.log(item.group)
html = html.replace(
reg,
`<img
......
......@@ -4,6 +4,8 @@ async function getInfo() {
return data as { version: string; updateInfo: string }
}
const LOOP_INTERVAL = 1000 * 60 * 2 // 2 minutes
export async function loopGetVersion() {
const { version } = await getInfo()
let hasShow = false
......@@ -49,5 +51,5 @@ export async function loopGetVersion() {
]),
})
hasShow = true
}, 10000)
}, LOOP_INTERVAL)
}
......@@ -210,7 +210,7 @@ const onBid = async (item: AuctionItemDto) => {
<div class="flex gap-3 relative">
<BackButton />
<div class="flex-3/4 min-w-0">
<div class="bg-white/90 rounded-2xl p-6 shadow-lg border border-white/60">
<div class="bg-white/90 rounded-lg p-6 shadow-lg border border-white/60">
<div class="flex items-center gap-3 mb-6">
<div
class="w-1.5 h-7 bg-gradient-to-b from-[--un-gradient-from] to-[--un-gradient-to] rounded-full shadow-sm"
......@@ -367,7 +367,7 @@ const onBid = async (item: AuctionItemDto) => {
<!-- 右侧:信息面板 -->
<div class="flex-1/4 space-y-6">
<!-- YA币信息卡片 -->
<div class="bg-white/90 rounded-2xl p-6 shadow-lg border border-white/60 sticky top-[52px]">
<div class="bg-white/90 rounded-lg p-6 shadow-lg border border-white/60 sticky top-[52px]">
<div class="text-center mb-6">
<div class="text-gray-500 text-sm mb-2">当前YA币</div>
<div
......
......@@ -44,6 +44,7 @@ export default defineComponent(() => {
{ path: '/backend/settingsMenu/auctionManage', title: '限时竞拍配置' },
{ path: '/backend/settingsMenu/goodsManage', title: '积分商城配置' },
{ path: '/backend/settingsMenu/dailyLotteryManage', title: '每日抽奖配置' },
{ path: '/backend/settingsMenu/luckyWheelManage', title: '转盘抽奖配置' },
],
},
// 栏目管理
......
......@@ -58,10 +58,10 @@
</el-table-column>
<el-table-column label="操作" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)" :disabled="row.isCurrent"
<el-button type="primary" link @click="handleEdit(row)" :disabled="!!row.isCurrent"
>编辑</el-button
>
<el-button type="danger" link @click="handleDelete(row)" :disabled="row.isCurrent"
<el-button type="danger" link @click="handleDelete(row)" :disabled="!!row.isCurrent"
>删除</el-button
>
</template>
......
<template>
<el-dialog v-model="visible" title="抽奖配置" width="500px">
<el-form :model="form" label-width="100px" :rules="formRules" ref="formRef">
<el-form-item label="开放时间" prop="openRangeTime">
<el-date-picker
v-model="form.openRangeTime"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="X"
/>
</el-form-item>
<el-form-item label="参与费用" prop="costYaCoin">
<el-input-number
v-model="form.costYaCoin"
:min="0"
:max="1000000"
controls-position="right"
/>
<span class="ml-2">YA币</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { getWheelConfig, setWheelConfig } from '@/api/backend'
import { useResetData } from '@/hooks'
import type { FormInstance, FormRules } from 'element-plus'
import { push } from 'notivue'
import { formatSeconds } from '@/utils'
const formRules: FormRules = {
openRangeTime: [{ required: true, message: '请选择开放时间', trigger: 'change' }],
costYaCoin: [{ required: true, message: '请输入参与费用', trigger: 'change' }],
}
const [form, resetForm] = useResetData<{
openRangeTime: string[]
costYaCoin: number
}>({
openRangeTime: [],
costYaCoin: 0,
})
const visible = ref(false)
const formRef = ref<FormInstance>()
const loading = ref(false)
const open = async () => {
const { data } = await getWheelConfig()
if (!data) {
resetForm()
} else {
form.value = {
openRangeTime: [String(data.startTime), String(data.endTime)],
costYaCoin: data.costYaCoin,
}
}
visible.value = true
}
const handleSubmit = async () => {
await formRef.value?.validate()
loading.value = true
try {
await setWheelConfig({
startTime: form.value.openRangeTime[0]!,
endTime: formatSeconds(form.value.openRangeTime[1]!),
costYaCoin: form.value.costYaCoin,
})
push.success('保存成功')
visible.value = false
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>
<template>
<div class="official-tag-page">
<!-- 搜索栏 -->
<div class="search-section">
<el-input
v-model="searchParams.name"
placeholder="请输入奖品名称"
clearable
class="w-200px! mr-12px"
/>
<el-select
v-model="searchParams.isEnabled"
placeholder="是否启用"
clearable
class="w-200px! mr-12px"
>
<el-option label="是" :value="BooleanFlag.YES" />
<el-option label="否" :value="BooleanFlag.NO" />
</el-select>
<el-button type="primary" @click="refresh">
<el-icon><IEpSearch /></el-icon>
搜索
</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><IEpPlus /></el-icon>
新增
</el-button>
<el-button type="primary" @click="handleWheelConfig">
<el-icon class="mr-2"><IEpSetting /></el-icon>
抽奖配置
</el-button>
<el-button class="config-tip-button" @click="configTipVisible = true">
<el-icon class="mr-2"><IEpInfoFilled /></el-icon>
配置提示
</el-button>
</div>
<!-- 表格区域 -->
<div class="table-section">
<div class="table-wrapper">
<el-table v-loading="loading" :data="list" height="100%">
<el-table-column prop="name" label="名称" />
<el-table-column prop="imageUrl" label="图片">
<template #default="{ row }">
<el-image
v-if="row.imageUrl"
:preview-teleported="true"
:src="row.imageUrl"
class="w-20 h-20 object-cover"
:preview-src-list="[row.imageUrl]"
/>
<span v-else>暂无图片</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="奖品数量" />
<el-table-column prop="probability" label="中奖概率">
<template #default="{ row }"> {{ row.probability }}% </template>
</el-table-column>
<el-table-column prop="isEnabled" label="是否启用">
<template #default="{ row }">
{{ row.isEnabled === BooleanFlag.YES ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间">
<template #default="{ row }">
{{ dayjs(row.createdAt * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="140">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="searchParams.current"
v-model:page-size="searchParams.size"
:total="total"
:page-sizes="[10, 20, 30]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="changePageSize"
@current-change="goToPage"
/>
</div>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="510px"
top="20vh"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="auto">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称(最多25个字符)" maxlength="25" />
</el-form-item>
<el-form-item label="图片" prop="imageUrl">
<UploadFile v-model="form.imageUrl" :limit="1" />
</el-form-item>
<el-form-item label="奖品数量" prop="quantity">
<el-input-number
v-model="form.quantity"
:min="0"
:max="999999"
controls-position="right"
/>
</el-form-item>
<el-form-item label="中奖概率" prop="probability">
<el-input-number
v-model="form.probability"
:min="0"
:max="100"
:precision="2"
:step="0.01"
controls-position="right"
/>
<span class="ml-2">%</span>
</el-form-item>
<el-form-item label="是否启用" prop="isEnabled">
<el-switch
v-model="form.isEnabled"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="是"
inactive-text="否"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon class="btn-icon"><IEpUpload /></el-icon>
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="configTipVisible" width="620px" top="18vh" class="config-tip-dialog">
<div class="config-tip-panel">
<div class="config-tip-hero">
<div class="config-tip-icon">
<el-icon><IEpInfoFilled /></el-icon>
</div>
<div>
<div class="config-tip-title">转盘配置建议</div>
<p class="config-tip-subtitle">
为了保证抽奖逻辑和页面展示效果正常,建议在配置前先确认以下规则。
</p>
</div>
</div>
<div class="config-tip-list">
<div class="config-tip-item">
<div class="config-tip-index">01</div>
<div class="config-tip-content">
<div class="config-tip-item-title">中奖概率总和不能超过 100%</div>
<p>
所有已启用奖品的中奖概率相加后不得超过 100%。如果总概率小于
100%,剩余概率将自动落到“祝福语”上,也就是“谢谢参与”。
</p>
</div>
</div>
<div class="config-tip-item">
<div class="config-tip-index">02</div>
<div class="config-tip-content">
<div class="config-tip-item-title">建议总配置项保持为偶数</div>
<p>
“已启用奖品”加上“谢谢参与”后的总数量,建议保持为偶数(如图1);如果是奇数,转盘分区在页面上的展示会不够协调,视觉效果会受影响(如图2)。
</p>
<div class="config-tip-example">
<el-image
v-for="(image, index) in configTipImages"
:key="`${image}-${index}`"
:src="image"
:preview-src-list="configTipImages"
:initial-index="index"
fit="cover"
preview-teleported
class="config-tip-example-image"
/>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
<WheelConfig ref="wheelConfigRef" />
</div>
</template>
<script setup lang="tsx">
import { usePageSearch, useResetData } from '@/hooks'
import { getWheelPrizeList, addOrUpdateWheelPrize, deleteWheelPrize } from '@/api/backend'
import type { FormInstance, FormRules } from 'element-plus'
import type { BackendWheelPrizeListItemDto, BackendAddOrUpdateWheelPrizeDto } from '@/api/backend'
import UploadFile from '@/components/common/UploadFile/index.vue'
import dayjs from 'dayjs'
import WheelConfig from './components/wheelConfigDialog.vue'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
import { BooleanFlag } from '@/constants'
const { confirm } = useMessageBox()
const { loading, list, total, reset, goToPage, changePageSize, refresh, searchParams, search } =
usePageSearch(getWheelPrizeList)
const dialogVisible = ref(false)
const configTipVisible = ref(false)
const configTipImages = [
'https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/readName/png/2026/04/03/Common/1775212125999.png',
'https://soundasia.oss-cn-shenzhen.aliyuncs.com/OA/readName/png/2026/04/03/Common/1775212079319.png',
]
const dialogTitle = computed(() => (form.value.id ? '编辑' : '新增'))
const formRef = ref<FormInstance>()
const [form, resetForm] = useResetData<BackendAddOrUpdateWheelPrizeDto>({
id: undefined,
imageUrl: '',
name: '',
quantity: 0,
probability: 0,
isEnabled: BooleanFlag.YES,
})
const formRules: FormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
imageUrl: [{ required: true, message: '请上传图片', trigger: 'change' }],
quantity: [{ required: true, message: '请输入奖品数量', trigger: 'change' }],
probability: [{ required: true, message: '请输入中奖概率', trigger: 'change' }],
isEnabled: [{ required: true, message: '请选择是否启用', trigger: 'change' }],
}
const submitLoading = ref(false)
const handleAdd = () => {
resetForm()
dialogVisible.value = true
}
const handleEdit = (row: BackendWheelPrizeListItemDto) => {
resetForm()
form.value = {
id: row.id,
name: row.name,
imageUrl: row.imageUrl,
quantity: row.quantity,
probability: row.probability,
isEnabled: row.isEnabled,
}
dialogVisible.value = true
}
const handleDelete = async (row: BackendWheelPrizeListItemDto) => {
await confirm({
title: '提示',
message: `确定要删除奖品"${row.name}"吗?`,
type: 'danger',
})
try {
await deleteWheelPrize([row.id])
push.success('删除成功')
refresh()
} catch (error) {
if (error !== 'cancel') {
push.error('删除失败')
}
}
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
submitLoading.value = true
await formRef.value.validate()
await addOrUpdateWheelPrize(form.value)
push.success(form.value.id ? '编辑成功' : '新增成功')
dialogVisible.value = false
if (form.value.id) {
search()
} else {
refresh()
}
} catch (error) {
console.error('表单验证失败:', error)
} finally {
submitLoading.value = false
}
}
const wheelConfigRef = ref<InstanceType<typeof WheelConfig>>()
const handleWheelConfig = () => {
wheelConfigRef.value?.open()
}
</script>
<style scoped lang="scss">
.official-tag-page {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.search-section {
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
flex-wrap: wrap;
gap: 12px 0;
flex-shrink: 0;
}
.table-section {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-wrapper {
flex: 1;
min-height: 0;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
flex-shrink: 0;
}
.btn-icon {
margin-right: 4px;
}
.config-tip-button {
border-color: #c9d7ff;
color: #3b6ff5;
background: linear-gradient(135deg, #f7faff 0%, #eef4ff 100%);
}
.config-tip-button:hover {
border-color: #9db7ff;
color: #2f5de0;
background: linear-gradient(135deg, #eef4ff 0%, #e4edff 100%);
}
.config-tip-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-tip-hero {
display: flex;
gap: 16px;
padding: 18px 20px;
border-radius: 16px;
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
border: 1px solid #dbe6ff;
}
.config-tip-icon {
width: 44px;
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
font-size: 22px;
color: #3b6ff5;
background: #fff;
box-shadow: 0 10px 24px rgba(59, 111, 245, 0.12);
}
.config-tip-title {
font-size: 18px;
font-weight: 600;
color: #1f2a44;
line-height: 1.4;
}
.config-tip-subtitle {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.7;
color: #5b6475;
}
.config-tip-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.config-tip-item {
display: flex;
gap: 14px;
padding: 18px 20px;
border-radius: 16px;
background: #fff;
border: 1px solid #ebeef5;
box-shadow: 0 8px 24px rgba(31, 42, 68, 0.05);
}
.config-tip-index {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.config-tip-content {
flex: 1;
}
.config-tip-item-title {
margin-bottom: 6px;
font-size: 15px;
font-weight: 600;
color: #1f2a44;
}
.config-tip-content p {
margin: 0;
font-size: 13px;
line-height: 1.8;
color: #5b6475;
}
.config-tip-example {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.config-tip-example-image {
border-radius: 12px;
overflow: hidden;
border: 1px solid #e5eaf3;
box-shadow: 0 8px 20px rgba(31, 42, 68, 0.08);
cursor: zoom-in;
}
:deep(.config-tip-dialog .el-dialog) {
border-radius: 20px;
overflow: hidden;
}
:deep(.config-tip-dialog .el-dialog__header) {
margin-right: 0;
padding: 22px 24px 0;
}
:deep(.config-tip-dialog .el-dialog__body) {
padding: 18px 24px 8px;
}
:deep(.config-tip-dialog .el-dialog__footer) {
padding: 0 24px 24px;
}
</style>
......@@ -71,7 +71,7 @@
</div>
</div>
</div>
<div class="right flex-col gap-3 flex basis-1/4 xl:basis-1/4 min-w-0">
<div class="right flex-col gap-3 basis-1/4 xl:basis-1/4 min-w-0 hidden sm:flex">
<!-- 等级等相关信息 -->
<div
ref="levelContainerRef"
......@@ -298,67 +298,87 @@
</transition>
</div>
</div>
<!-- 每日抽奖 -->
<div v-if="lotteryPrizesDetail" class="lottery-container common-box rounded-lg bg-#FFF7E6">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-gradient-to-b from-amber-400 to-orange-400 rounded-full"></div>
<h1 class="text-sm sm:text-base font-bold">每日抽奖</h1>
</div>
<template v-if="!isWareHouse">
<!-- 每日抽奖 -->
<div
class="flex items-center gap-3 min-w-0 p-3 rounded-lg bg-white/60 border border-amber-100"
v-if="lotteryPrizesDetail"
class="lottery-container common-box rounded-lg bg-#FFF7E6"
>
<el-image
class="w-18 h-18 rounded-lg flex-shrink-0 shadow-sm"
:src="lotteryPrizesDetail.prizesImg"
fit="cover"
:preview-src-list="[lotteryPrizesDetail.prizesImg]"
/>
<div class="flex flex-col min-w-0 gap-1">
<div class="font-semibold text-sm truncate">
{{ lotteryPrizesDetail.prizesName }}
</div>
<div class="text-12px text-gray-500 flex items-center gap-1">
<svg-icon name="small_coin" size="14" />
<span>{{ lotteryPrizesDetail.registrationFee }} YA币</span>
</div>
<div class="text-12px text-gray-400">今日{{ lotteryPrizesDetail.number }}人参与</div>
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-gradient-to-b from-amber-400 to-orange-400 rounded-full"></div>
<h1 class="text-sm sm:text-base font-bold">每日抽奖</h1>
</div>
</div>
<div v-if="lotteryPrizesDetail.winner" class="text-xs text-amber-600 mt-2 px-1 truncate">
昨日中奖:{{ lotteryPrizesDetail.winner }}
</div>
<div class="flex justify-center items-center mt-3">
<el-button
v-if="!lotteryPrizesDetail.isJoin"
class="w-full! border-none! transition-all duration-200 text-xs sm:text-sm bg-[linear-gradient(to_right,#FFD06A_0%,#FFB143_100%)]! text-#333! shadow-[0px_1px_8px_0_rgba(255,173,91,0.25)] hover:-translate-y-0.5 hover:shadow-[0px_4px_10px_0_rgba(255,173,91,0.4)] active:translate-y-0"
type="primary"
@click="handleLottery"
<div
class="flex items-center gap-3 min-w-0 p-3 rounded-lg bg-white/60 border border-amber-100"
>
<svg-icon name="daily_lottery" size="18" class="mr-1" />
参与抽奖
</el-button>
<el-image
class="w-18 h-18 rounded-lg flex-shrink-0 shadow-sm"
:src="lotteryPrizesDetail.prizesImg"
fit="cover"
:preview-src-list="[lotteryPrizesDetail.prizesImg]"
/>
<div class="flex flex-col min-w-0 gap-1">
<div class="font-semibold text-sm truncate">
{{ lotteryPrizesDetail.prizesName }}
</div>
<div class="text-12px text-gray-500 flex items-center gap-1">
<svg-icon name="small_coin" size="14" />
<span>{{ lotteryPrizesDetail.registrationFee }} YA币</span>
</div>
<div class="text-12px text-gray-400">
今日{{ lotteryPrizesDetail.number }}人参与
</div>
</div>
</div>
<div
v-else
class="w-full text-center py-2 rounded-md bg-amber-50 border border-amber-200 border-dashed text-amber-400 text-xs sm:text-sm"
v-if="lotteryPrizesDetail.winner"
class="text-xs text-amber-600 mt-2 px-1 truncate"
>
✓ 今日已参与
昨日中奖:{{ lotteryPrizesDetail.winner }}
</div>
</div>
</div>
<!-- 大转盘 -->
<!-- <div class="lottery-container common-box rounded-lg bg-#F5F0FF">
<div class="flex items-center gap-2 mb-4">
<div class="w-1 h-4 bg-gradient-to-b from-violet-500 to-purple-500 rounded-full"></div>
<h1 class="text-sm sm:text-base font-bold">大转盘</h1>
<div class="flex justify-center items-center mt-3">
<el-button
v-if="!lotteryPrizesDetail.isJoin"
class="w-full! border-none! transition-all duration-200 text-xs sm:text-sm bg-[linear-gradient(to_right,#FFD06A_0%,#FFB143_100%)]! text-#333! shadow-[0px_1px_8px_0_rgba(255,173,91,0.25)] hover:-translate-y-0.5 hover:shadow-[0px_4px_10px_0_rgba(255,173,91,0.4)] active:translate-y-0"
type="primary"
@click="handleLottery"
>
<svg-icon name="daily_lottery" size="18" class="mr-1" />
参与抽奖
</el-button>
<div
v-else
class="w-full text-center py-2 rounded-md bg-amber-50 border border-amber-200 border-dashed text-amber-400 text-xs sm:text-sm"
>
✓ 今日已参与
</div>
</div>
</div>
<div class="flex justify-center">
<LuckyWheel />
<!-- 大转盘 -->
<div
v-if="wheelConfig?.isActivityActive"
class="lottery-container common-box rounded-lg bg-#F5F0FF"
>
<div class="flex items-center gap-2 xl:mb-4">
<div
class="w-1 h-4 bg-gradient-to-b from-violet-500 to-purple-500 rounded-full"
></div>
<h1 class="text-sm sm:text-base font-bold">大转盘</h1>
</div>
<div class="flex justify-center">
<LuckyWheelContainer :wheelConfig="wheelConfig" />
</div>
<div
class="flex items-center justify-center text-sm text-gray-500 xl:mt-4 px-1 truncate"
>
每次抽奖{{ wheelConfig?.costYaCoin }} YA币
</div>
</div>
</div> -->
</template>
</div>
</div>
......@@ -384,6 +404,7 @@ import {
getRecordData,
getUserDailyLotteryInfo,
userJoinLottery,
getWheelConfig,
} from '@/api'
import { TaskTypeEnum, TaskDateLimitTypeText, ArticleTypeEnum } from '@/constants'
import type {
......@@ -392,6 +413,7 @@ import type {
UserAccountDataDto,
UserRecordDataDto,
DailyLotteryInfo,
WheelConfigDto,
} from '@/api'
import { TABS_REF_KEY, levelListOptions } from '@/constants'
import { useScrollTop, useMessageBox } from '@/hooks'
......@@ -399,11 +421,14 @@ import { useQuestionStore, useActivityStore, useYaBiStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { push } from 'notivue'
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
// import LuckyWheel from '@/components/common/LuckyWheel/index.vue'
import LuckyWheelContainer from '@/components/common/LuckyWheelContainer/index.vue'
import { RewardButtonEnum } from '@/constants'
import RewardButton from '@/components/common/RewardButton/index.vue'
import { useTourStore } from '@/stores'
import { useTourStore, useUserStore } from '@/stores'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const isWareHouse = userInfo.value.address.includes('仓库')
const { confirm } = useMessageBox()
const tourStore = useTourStore()
const { shouldShowAskTabTour } = storeToRefs(tourStore)
......@@ -540,6 +565,11 @@ const handleLottery = async () => {
push.success('参与每日抽奖成功!')
}
/**
* 大转盘
*/
const wheelConfig = ref<WheelConfigDto>(null)
const handleTask = async (item: TaskItemDto) => {
if (item.currentCount === item.limitCount) return
// 先暂时写死
......@@ -592,7 +622,8 @@ const initPage = () => {
getUserAccountData(),
getRecordData(),
getUserDailyLotteryInfo(),
]).then(([r1, r2, r3, r4]) => {
getWheelConfig(),
]).then(([r1, r2, r3, r4, r5]) => {
if (r1.status === 'fulfilled') {
carouselList.value = r1.value.data
}
......@@ -606,6 +637,9 @@ const initPage = () => {
if (r4.status === 'fulfilled') {
lotteryPrizesDetail.value = r4.value.data
}
if (r5.status === 'fulfilled') {
wheelConfig.value = r5.value.data
}
})
}
......@@ -631,6 +665,7 @@ onActivated(async () => {
refreshTaskData(false)
refreshUserAccountData()
getLotteryPrizesDetail()
if (route.fullPath.includes('#levelContainerRef')) {
await handleBackTop()
open.value = true
......
......@@ -75,7 +75,7 @@
</div>
<div class="flex flex-wrap gap-2">
<label
v-for="item in firstDepList"
v-for="item in firstDepListOptions"
:key="item.id"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium cursor-pointer transition-all duration-200',
......@@ -228,14 +228,10 @@ import { getPracticeList } from '@/api/practice'
import { usePageSearch, useScrollTop } from '@/hooks'
import dayjs from 'dayjs'
import { TABS_REF_KEY } from '@/constants'
// import { useTagsStore } from '@/stores/tags'
// import { storeToRefs } from 'pinia'
import PublishPractice from '@/components/common/PublishBox/index.vue'
import { ArticleTypeEnum } from '@/constants'
import { ArticleTypeEnum ,firstDepListOptions} from '@/constants'
import { useNavigation } from '@/hooks'
// const tagsStore = useTagsStore()
// const { tagList } = storeToRefs(tagsStore)
const router = useRouter()
const { jumpToArticleDetailPage } = useNavigation()
......@@ -245,22 +241,6 @@ const filterOptions = ref([
{ title: '最新', id: 1 },
{ title: '最热', id: 0 },
])
const firstDepList = ref([
{ id: '1825', name: 'Amazon产品开发部' },
{ id: '76', name: 'Amazon销售部' },
{ id: '139', name: 'Amazon运营部' },
{ id: '5114', name: 'Temu销售部' },
{ id: '4491', name: 'Tiktok项目部' },
{ id: '39', name: '视觉设计部' },
{ id: '23', name: '知识产权部' },
{ id: '499', name: '供应链管理部' },
{ id: '20', name: 'IT技术部' },
{ id: '5', name: '财务部' },
{ id: '2357', name: '人力发展中心' },
{ id: '683', name: '副总经理' },
{ id: '6', name: '总经理' },
{ id: '3', name: '董事长' },
])
const { handleBackTop, ScrollTopComp } = useScrollTop(tabsRef!)
const { list, total, searchParams, goToPage, changePageSize, refresh } = usePageSearch(
getPracticeList,
......
......@@ -19,19 +19,26 @@
:src="userInfo?.showAvatar"
class="border-4 border-white shadow-lg"
/>
<div class="flex-1">
<h2 class="text-xl font-semibold text-gray-800 mb-1 mt-4">
{{ userInfo?.showName }}
</h2>
<p v-if="!+(route.params.isReal as string)" class="text-gray-500 text-sm mb-2">
{{ userInfo?.signature }}
</p>
<!-- <el-button type="warning" size="small" plain @click="handleEdit">
<el-icon>
<IEpEdit />
</el-icon>
修改资料
</el-button> -->
<div
class="flex-1 min-w-0 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<h2 class="text-xl font-semibold text-gray-800 mb-1 mt-4 sm:mt-1">
{{ userInfo?.showName }}
</h2>
<p v-if="!+(route.params.isReal as string)" class="text-gray-500 text-sm mb-0">
{{ userInfo?.signature }}
</p>
</div>
<button
v-if="canSendPrivateMessage"
type="button"
class="shrink-0 self-start sm:self-auto inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50/90 border border-blue-100 rounded-lg hover:bg-blue-100/80 hover:border-blue-200/80 active:scale-[0.98] transition-colors cursor-pointer"
@click="openSendMessage"
>
<el-icon class="text-[15px] text-blue-600"><IEpChatDotRound /></el-icon>
私信
</button>
</div>
</div>
</div>
......@@ -69,9 +76,9 @@
</div>
<div class="flex items-center text-gray-400 text-sm ml-4">
<el-link type="primary" link>
<span class="text-blue-600">
{{ articleTypeListOptions.find((i) => i.value === item.type)?.label || '--' }}
</el-link>
</span>
</div>
<!-- <el-divider /> -->
</div>
......@@ -90,45 +97,72 @@
</div>
</div>
</div>
<SendMessageDialog ref="sendMessageDialogRef" />
</div>
</template>
<script lang="tsx" setup>
import { hasOfficialAccount, getOtherUserData, getOtherUserPostData } from '@/api'
import type { OfficialAccountItemDto } from '@/api/user/types'
import { getOtherUserData, getOtherUserPostData } from '@/api'
import dayjs from 'dayjs'
import { usePageSearch, useNavigation } from '@/hooks'
import { articleTypeListOptions } from '@/constants'
import { articleTypeListOptions, BooleanFlag } from '@/constants'
import type { OtherUserInfoDto } from '@/api/otherUserPage/types'
import BackButton from '@/components/common/BackButton/index.vue'
import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue'
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
const route = useRoute()
const { jumpToArticleDetailPage } = useNavigation()
const { list, total, searchParams, goToPage, changePageSize } = usePageSearch(
const userStore = useUserStore()
const { userInfo: loginUser } = storeToRefs(userStore)
const sendMessageDialogRef = ref<InstanceType<typeof SendMessageDialog> | null>(null)
const targetUserId = computed(() => (route.params.userId as string) || '')
const routeIsRealFlag = computed(() =>
+(route.params.isReal as string) === 1 ? BooleanFlag.YES : BooleanFlag.NO,
)
const canSendPrivateMessage = computed(() => {
const id = targetUserId.value
return !!id && id !== loginUser.value.userId
})
const openSendMessage = () => {
sendMessageDialogRef.value?.open({
receiverId: targetUserId.value,
isReal: routeIsRealFlag.value,
})
}
const { list, total, searchParams, goToPage, changePageSize, refresh } = usePageSearch(
getOtherUserPostData,
{
defaultParams: {
createUserId: route.params.userId as string,
isReal: +(route.params.isReal as string),
},
immediate: false,
},
)
const officialAccountList = ref<OfficialAccountItemDto[]>([])
const getIsOfficial = async () => {
const { data } = await hasOfficialAccount()
officialAccountList.value = data
}
const userInfo = ref<OtherUserInfoDto>({} as OtherUserInfoDto)
onMounted(async () => {
// onMounted(async () => {
// const { data } = await getOtherUserData({
// userId: route.params.userId as string,
// isReal: +(route.params.isReal as string),
// })
// userInfo.value = data
// })
onActivated(async () => {
const { data } = await getOtherUserData({
userId: route.params.userId as string,
isReal: +(route.params.isReal as string),
})
userInfo.value = data
})
onMounted(() => {
getIsOfficial()
refresh()
})
</script>
......
......@@ -5,7 +5,7 @@
<!-- 左侧:商品列表区域 -->
<div class="flex-3/4 min-w-0">
<!-- 虚拟装饰区域 -->
<div class="bg-white/90 rounded-2xl p-6 shadow-lg mb-3 border border-white/60">
<div class="bg-white/90 rounded-lg p-6 shadow-lg mb-3 border border-white/60">
<div class="flex items-center gap-3 mb-6">
<div
class="w-1.5 h-7 bg-gradient-to-b from-[#8b5cf6] to-[#6366f1] rounded-full shadow-sm"
......@@ -70,7 +70,7 @@
</div>
<!-- 实物奖品区域 -->
<div class="bg-white/90 rounded-2xl p-6 shadow-lg border border-white/60">
<div class="bg-white/90 rounded-lg p-6 shadow-lg border border-white/60">
<div class="flex items-center gap-3 mb-6">
<div
class="w-1.5 h-7 bg-gradient-to-b from-[#8b5cf6] to-[#6366f1] rounded-full shadow-sm"
......@@ -158,7 +158,7 @@
<!-- 右侧:信息面板 -->
<div class="flex-1/4 flex-shrink-0 space-y-6">
<!-- YA币信息卡片 -->
<div class="bg-white/90 rounded-2xl p-3 shadow-lg border border-white/60 sticky top-[52px]">
<div class="bg-white/90 rounded-lg p-3 shadow-lg border border-white/60 sticky top-[52px]">
<div class="text-center mb-6">
<div class="text-gray-500 text-sm mb-2">当前YA币</div>
<div
......
......@@ -63,7 +63,7 @@
</div>
<div class="flex flex-wrap gap-2">
<label
v-for="item in firstDepList"
v-for="item in firstDepListOptions"
:key="item.id"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium cursor-pointer transition-all duration-200',
......@@ -241,6 +241,7 @@ import { usePageSearch, useScrollTop, useNavigation } from '@/hooks'
import dayjs from 'dayjs'
import { storeToRefs } from 'pinia'
import { useTagsStore } from '@/stores/tags'
import { firstDepListOptions } from '@/constants'
const tagsStore = useTagsStore()
const { tagList } = storeToRefs(tagsStore)
......@@ -249,22 +250,7 @@ const route = useRoute()
const searchPageRef = ref<HTMLElement | null>(null)
const { handleBackTop } = useScrollTop(searchPageRef)
const { jumpToArticleDetailPage } = useNavigation()
const firstDepList = ref([
{ id: '1825', name: 'Amazon产品开发部' },
{ id: '76', name: 'Amazon销售部' },
{ id: '139', name: 'Amazon运营部' },
{ id: '5114', name: 'Temu销售部' },
{ id: '4491', name: 'Tiktok项目部' },
{ id: '39', name: '视觉设计部' },
{ id: '23', name: '知识产权部' },
{ id: '499', name: '供应链管理部' },
{ id: '20', name: 'IT技术部' },
{ id: '5', name: '财务部' },
{ id: '2357', name: '人力发展中心' },
{ id: '683', name: '副总经理' },
{ id: '6', name: '总经理' },
{ id: '3', name: '董事长' },
])
const sortOptions = [
{ label: '最新', value: 1 },
{ label: '最热', value: 0 },
......
......@@ -235,8 +235,13 @@
</template>
</div>
</template>
<!-- 如果是官方账号的话还有推送相关的配置 帖子也要加推送-->
<template v-if="userInfo.isOfficialAccount && form.type === ArticleTypeEnum.POST">
<!-- 如果是官方账号的话还有推送相关的配置 帖子 和问吧也要加推送-->
<template
v-if="
userInfo.isOfficialAccount &&
(form.type === ArticleTypeEnum.POST || form.type === ArticleTypeEnum.QUESTION)
"
>
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
......@@ -280,6 +285,19 @@
/>
</el-form-item>
</template>
<!-- 选一级部门 如果是官方账号 并且是 实践 选填 -->
<template v-if="userInfo.isOfficialAccount && form.type === ArticleTypeEnum.PRACTICE">
<el-form-item label="所属一级部门">
<el-select v-model="form.deptId" :placeholder="`请选择一级部门`" clearable>
<el-option
v-for="item in firstDepListOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</el-select>
</el-form-item>
</template>
<!-- 发布设置 -->
<div>
<el-form-item label="发布时间" prop="sendType">
......@@ -320,7 +338,11 @@
<template #footer>
<div class="flex gap-3 justify-end">
<el-button @click="handleDrawerClose" class="rounded-lg">取消</el-button>
<el-button @click="handleSubmit(ReleaseStatusTypeEnum.DRAFT)" class="rounded-lg">
<el-button
:loading="loading"
@click="handleSubmit(ReleaseStatusTypeEnum.DRAFT)"
class="rounded-lg"
>
存草稿
</el-button>
<el-button
......@@ -347,6 +369,7 @@ import {
ReleaseStatusTypeEnum,
articleTypeListOptions,
VideoPositionEnum,
firstDepListOptions,
} from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import UploadVideo from '@/components/common/UploadVideo/index.vue'
......@@ -411,6 +434,7 @@ const [form, resetForm] = useResetData({
// 视频
videoLocation: VideoPositionEnum.TOP,
articleVideoUrl: '',
deptId: '',
})
// 时间选择只允许整十分(:00、:10、:20、:30、:40、:50),秒固定为 0
......@@ -500,11 +524,11 @@ const transFormData = (releaseStatus: ReleaseStatusTypeEnum) => {
data.faceUrl = data.imgUrl.split(',')[0] || ''
}
}
if (
(data.type === ArticleTypeEnum.COLUMN ||
data.type === ArticleTypeEnum.INTERVIEW ||
data.type === ArticleTypeEnum.POST) &&
data.type === ArticleTypeEnum.POST ||
data.type === ArticleTypeEnum.QUESTION) &&
userInfo.value.isOfficialAccount
) {
if (data.isPushAll === BooleanFlag.YES) {
......@@ -531,6 +555,9 @@ const transFormData = (releaseStatus: ReleaseStatusTypeEnum) => {
}
}
if (data.type === ArticleTypeEnum.PRACTICE && userInfo.value.isOfficialAccount) {
data.deptId = data.deptId || ''
}
return data
}
......@@ -556,11 +583,11 @@ const handleSubmit = async (releaseStatus: ReleaseStatusTypeEnum) => {
const validateRes = await formRef.value?.validate()
console.log(validateRes, 'validateRes')
loading.value = true
const res =
form.value.type === ArticleTypeEnum.PRACTICE
? await addOrUpdatePractice(transFormData(releaseStatus))
: await addOrUpdateArticle(transFormData(releaseStatus))
console.log(res)
if (form.value.type === ArticleTypeEnum.PRACTICE) {
await addOrUpdatePractice(transFormData(releaseStatus))
} else {
await addOrUpdateArticle(transFormData(releaseStatus))
}
drawerVisible.value = false
push.success(releaseStatus === ReleaseStatusTypeEnum.PUBLISH ? '发布成功' : '存草稿成功')
resetForm()
......@@ -635,7 +662,8 @@ onActivated(async () => {
if (
(type === ArticleTypeEnum.COLUMN ||
type === ArticleTypeEnum.INTERVIEW ||
type === ArticleTypeEnum.POST) &&
type === ArticleTypeEnum.POST ||
type === ArticleTypeEnum.QUESTION) &&
userInfo.value.isOfficialAccount
) {
if (data.pushList?.find((item) => item.valueId == '1')) {
......@@ -660,6 +688,10 @@ onActivated(async () => {
})) || []
}
}
if (userInfo.value.isOfficialAccount && form.value.type === ArticleTypeEnum.PRACTICE) {
form.value.deptId = data.deptId === '0' ? '' : data.deptId
}
}
})
</script>
......
......@@ -116,7 +116,7 @@
>
{{ questionDetail.createUserName }}
</el-avatar>
<div class="flex flex-col">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm text-slate-900">
{{ questionDetail.createUserName }}
</span>
......@@ -132,6 +132,19 @@
{{ questionDetail.viewCount }}阅读
</span>
</div>
<button
v-if="!isAuthor && questionDetail.createUserId"
type="button"
class="shrink-0 inline-flex items-center px-3 py-1.5 text-xs font-semibold text-blue-600 bg-blue-50 rounded-lg border border-blue-100 hover:bg-blue-100 hover:border-blue-200/80 active:scale-[0.98] transition-all cursor-pointer"
@click="
sendMessageDialogRef?.open({
receiverId: String(questionDetail.createUserId),
isReal: BooleanFlag.YES,
})
"
>
私信
</button>
</div>
<!-- 标题:主要信息,黑重粗 -->
<h1 class="text-2xl font-bold text-slate-900 mb-3 leading-snug tracking-tight">
......@@ -227,9 +240,10 @@
@change="(val) => changeSortType(val as number)"
fill="#3b82f6"
>
<el-radio-button label="最新" :value="2" />
<el-radio-button label="最多点赞" :value="4" />
<el-radio-button label="最多评论" :value="1" />
<el-radio-button label="最多点赞" :value="CommentSortTypeEnum.MOST_LIKE" />
<el-radio-button label="最多评论" :value="CommentSortTypeEnum.MOST_COMMENT" />
<el-radio-button label="最早发布" :value="CommentSortTypeEnum.EARLIEST_PUBLISH" />
<el-radio-button label="最新发布" :value="CommentSortTypeEnum.NEWEST_PUBLISH" />
</el-radio-group>
<!-- <span
@click="changeSortType(2)"
......@@ -253,7 +267,8 @@
</div>
<!-- 3. 回答列表 -->
<div class="space-y-3">
<div class="space-y-3" v-loading="commentLoading">
<div
v-for="(answer, index) in list"
:ref="(el) => (answerRefList[index] = el as HTMLElement)"
......@@ -284,6 +299,18 @@
></span>
置顶回答
</span>
<span
class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-xs leading-none text-gray-500"
>
{{ answer.floorNumber || '顶' }}楼
</span>
<button
v-if="isSelfAnswer(answer)"
class="cursor-pointer text-red-500 text-xs leading-none"
@click="handleDeleteAnswer(answer)"
>
删除
</button>
</div>
</div>
</div>
......@@ -310,8 +337,9 @@
<!-- 正文 换行 -->
<div
class="text-slate-800 leading-7 mb-4 rich-text-content break-all"
v-html="parseEmoji(answer.content)"
class="text-slate-800 leading-7 mb-4 rich-text-content break-all whitespace-pre-wrap"
v-html="parseEmoji(answer.contentHtml || answer.content)"
v-parse-comment
></div>
<!-- 评论图片列表 -->
<div class="flex flex-wrap gap-3 mb-3">
......@@ -374,7 +402,7 @@
<Transition name="fade">
<div
v-show="answer.showComment"
class="mt-4 border border-slate-200 rounded-lg bg-slate-50/50 overflow-hidden"
class="mt-4 border border-slate-200 rounded-lg bg-slate-50/50"
>
<Comment
:authorId="questionDetail.createUserId"
......@@ -405,6 +433,7 @@
</div>
</div>
<CommentDialog ref="commentDialogRef" @commentSuccess="refresh" />
<SendMessageDialog ref="sendMessageDialogRef" />
</div>
</template>
<script setup lang="ts">
......@@ -415,22 +444,28 @@ import {
addOrCanceArticlelCollect,
addOrCancelCommentLike,
topOrCancelTopComment,
deleteComment,
} from '@/api'
import type { ArticleItemDto, CommentItemDto } from '@/api'
import { usePageSearch } from '@/hooks'
import Comment from '@/components/common/Comment/index.vue'
import CommentDialog from '@/components/common/CommentDialog/index.vue'
import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue'
import BackButton from '@/components/common/BackButton/index.vue'
import dayjs from 'dayjs'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
import { useNavigation, useScrollTop } from '@/hooks'
import { useMessageBox } from '@/hooks'
import { parseEmoji } from '@/utils/emoji'
import { ArticleTypeEnum, BooleanFlag } from '@/constants'
import { push } from 'notivue'
import { CommentSortTypeEnum } from '@/constants'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const { jumpToUserHomePage } = useNavigation()
const { confirm } = useMessageBox()
const route = useRoute()
const router = useRouter()
......@@ -439,10 +474,15 @@ const commentRefList = ref<InstanceType<typeof Comment>[]>([])
const answerRefList = ref<HTMLElement[]>([])
const questionDetail = ref<ArticleItemDto>({} as ArticleItemDto)
const commentDialogRef = useTemplateRef<typeof CommentDialog>('commentDialogRef')
const sendMessageDialogRef = useTemplateRef<InstanceType<typeof SendMessageDialog> | null>(
'sendMessageDialogRef',
)
// 回滚到子评论框
const { handleBackTop: handleBackTopChildren } = useScrollTop(answerRefList)
const loading = computed(() => !questionDetail.value.title)
const isSelfAnswer = (answer: CommentItemDto) =>
String(answer.userId || '') === String(userInfo.value.userId || '')
const isAuthor = computed(() => {
return questionDetail.value.createUserId === userInfo.value.userId
......@@ -464,11 +504,18 @@ const getQuestionDetail = async () => {
questionDetail.value = data
}
const { list, total, searchParams, goToPage, refresh } = usePageSearch(getCommentList, {
const {
list,
total,
searchParams,
goToPage,
refresh,
loading: commentLoading,
} = usePageSearch(getCommentList, {
// immediate: false,
defaultParams: {
articleId: questionId,
sortType: 2,
sortType: CommentSortTypeEnum.MOST_LIKE,
},
formatList: (list) =>
list.map((item) => ({
......@@ -508,6 +555,17 @@ const handleLikeAnswer = async (answer: CommentItemDto) => {
push.success(`${answer.hasPraise ? '点赞该回答' : '取消点赞该回答'}`)
}
const handleDeleteAnswer = async (answer: CommentItemDto) => {
await confirm({
title: '提示',
message: '确定删除该评论吗?',
type: 'danger',
})
await deleteComment(answer.id)
push.success('删除成功')
await refresh()
}
const topCommentPendingId = ref<number | null>(null)
const highlightCommentId = ref<number | null>(null)
const handleTopAnswer = async (answer: CommentItemDto) => {
......
<script setup lang="ts">
import CommentBox from '@/components/common/CommentBox/index.vue'
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
import BackButton from '@/components/common/BackButton/index.vue'
import type { ScrollbarInstance } from 'element-plus'
import { getMessageList, sendMessage, getMessageDetailList, deleteMessage } from '@/api'
import type { ConversationItem, MessageDetailListItem } from '@/api/selfMessage/types'
import { parseEmoji } from '@/utils/emoji'
import { push } from 'notivue'
import { BooleanFlag } from '@/constants'
import { useMessageBox } from '@/hooks'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const route = useRoute()
const { confirm } = useMessageBox()
const categoryTabs = [
{
key: BooleanFlag.YES,
label: '匿名私信',
},
{
key: BooleanFlag.NO,
label: '实名私信',
},
]
const conversationList = ref<ConversationItem[]>([])
const activeCategory = ref<BooleanFlag>(BooleanFlag.NO)
const activeConversationId = ref<number>(0)
const keyword = ref('')
const draftText = ref('')
const draftImages = ref('')
const sending = ref(false)
const conversationBoxRef = useTemplateRef<ScrollbarInstance>('conversationBoxRef')
const toChatType = (chatType: ConversationItem['chat_type']) => Number(chatType) as BooleanFlag
const listLoading = ref(false)
const getCategoryFromRoute = () => {
const isReal = Number(route.query.isReal)
if (isReal === 1) return BooleanFlag.NO
if (isReal === 0) return BooleanFlag.YES
return null
}
const applyCategoryFromRoute = () => {
const routeCategory = getCategoryFromRoute()
if (routeCategory === null || routeCategory === activeCategory.value) return false
activeCategory.value = routeCategory
activeConversationId.value = 0
messageDetailList.value = []
return true
}
const refreshConversationList = async (searchText = keyword.value.trim()) => {
listLoading.value = true
try {
const { data } = await getMessageList({
search: searchText || undefined,
})
conversationList.value = data?.dialogList || []
} finally {
listLoading.value = false
}
}
const filteredConversationList = computed(() => {
const list = conversationList.value.filter(
(item) => toChatType(item.chat_type) === activeCategory.value,
)
return list
})
const activeConversation = computed(() => {
return (
filteredConversationList.value.find((item) => item.id === activeConversationId.value) ?? null
)
})
const conversationStats = computed(() => ({
anonymous: conversationList.value.filter((item) => toChatType(item.chat_type) === BooleanFlag.YES)
.length,
realname: conversationList.value.filter((item) => toChatType(item.chat_type) === BooleanFlag.NO)
.length,
}))
const canSend = computed(() => {
return (
!!activeConversation.value &&
(!!draftText.value.trim() || !!draftImages.value.trim()) &&
!sending.value
)
})
watch(filteredConversationList, (list) => {
if (!list.length) {
activeConversationId.value = 0
return
}
// 仅当当前选中 id 已不在列表中时才清空,避免进入页面时自动选中第一项
if (
activeConversationId.value !== 0 &&
!list.some((item) => item.id === activeConversationId.value)
) {
activeConversationId.value = 0
messageDetailList.value = []
}
})
const messageDetailList = ref<MessageDetailListItem[]>([])
/** 拉取会话详情并滚动到底;不要在 watch(activeConversation) 里再调,否则会与点击逻辑并发请求两次 */
const selectConversation = async (item: ConversationItem, shouldScrollToBottom = true) => {
activeConversationId.value = item.id
const { data } = await getMessageDetailList({
receiverId: item.other_user_id,
dialogId: item.id,
chatType: activeCategory.value,
})
messageDetailList.value = data || []
if (shouldScrollToBottom) {
await nextTick()
scrollToBottom()
}
}
watch(
() => activeConversation.value?.id,
(id) => {
if (!id) return
const current = conversationList.value.find((item) => item.id === id)
if (current) {
current.un_read_count = 0
current.had_read = BooleanFlag.YES
}
},
)
const switchCategory = (category: BooleanFlag) => {
if (activeCategory.value === category) return
activeCategory.value = category
activeConversationId.value = 0
messageDetailList.value = []
}
const handleSearchBlur = async () => {
await refreshConversationList()
}
const handleSearchClear = () => {
keyword.value = ''
refreshConversationList('')
}
const scrollToBottom = () => {
const wrapper = conversationBoxRef.value
if (!wrapper?.wrapRef) return
wrapper.scrollTo({
top: wrapper.wrapRef.scrollHeight,
behavior: 'smooth',
})
}
const handleSend = async () => {
if (!canSend.value || !activeConversation.value) return
sending.value = true
try {
await sendMessage({
content: draftText.value,
chatType: activeCategory.value,
senderId: userInfo.value.userId,
receiverId: activeConversation.value.other_user_id,
images: draftImages.value,
})
draftText.value = ''
draftImages.value = ''
await selectConversation(activeConversation.value)
} finally {
sending.value = false
}
}
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}
const onConversationMenu = async (cmd: string | number, item: ConversationItem) => {
if (cmd !== 'delete') return
await confirm({
title: '删除会话',
message: '确定删除该会话?删除后聊天记录将不可恢复。',
confirmText: '删除',
cancelText: '取消',
type: 'warning',
})
try {
await deleteMessage({ type: 'user', idList: [item.id] })
conversationList.value = conversationList.value.filter((c) => c.id !== item.id)
// 选中会话被删时,由 watch(filteredConversationList) 把 activeConversationId 置 0 并清空 messageDetailList
push.success('已删除会话')
} catch {
/* 失败时请求封装已 toast */
}
}
const onMessageMenu = async (cmd: string | number, message: MessageDetailListItem) => {
if (cmd !== 'delete') return
if (!message.is_self) return
await confirm({
title: '删除消息',
message: '确定删除这条消息?',
confirmText: '删除',
cancelText: '取消',
type: 'warning',
})
await deleteMessage({ type: 'message', idList: [message.message_id] })
// await refreshConversationList()
if (activeConversation.value) await selectConversation(activeConversation.value, false)
push.success('已删除消息')
}
onActivated(async () => {
applyCategoryFromRoute()
await refreshConversationList()
})
</script>
<template>
<div class="rounded-lg relative">
<BackButton />
<section class="flex h-[calc(100vh-6.25rem)] gap-4 overflow-hidden">
<aside
class="flex-1/4 rounded-lg border border-[#d8e5f2] bg-white p-4 shadow-[0_0.5rem_1.5rem_rgba(134,167,207,0.12)]"
>
<div class="flex h-full flex-col">
<div>
<div class="mb-4 flex items-start justify-between gap-3">
<div>
<h2 class="m-0 text-4.5 font-700 text-[#17305d]">私信列表</h2>
</div>
</div>
<div class="mb-4 grid grid-cols-2 gap-2">
<button
v-for="item in categoryTabs"
:key="item.key"
class="cursor-pointer flex items-center justify-between rounded-lg border px-4 py-3 text-3.5 transition-all"
:class="
activeCategory === item.key
? 'border-[#8eadff] bg-[linear-gradient(180deg,#f4f8ff_0%,#eaf1ff_100%)] text-[#2f5ab7] shadow-[0_0.25rem_0.625rem_rgba(130,160,220,0.12)]'
: 'border-[#dfebf5] bg-[#f9fbfe] text-[#667b95]'
"
@click="switchCategory(item.key)"
>
<span>{{ item.label }}</span>
<span
class="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-[#e8eff8] px-1.5 text-3 font-700"
>
{{
item.key === BooleanFlag.YES
? conversationStats.anonymous
: conversationStats.realname
}}
</span>
</button>
</div>
<el-input
v-model="keyword"
size="large"
clearable
placeholder="搜索用户名"
class="mb-4"
:loading="listLoading"
@blur="handleSearchBlur"
@clear="handleSearchClear"
>
<template #prefix>
<el-icon><IEpSearch /></el-icon>
</template>
</el-input>
</div>
<div class="min-h-0 flex-1">
<el-scrollbar>
<div v-if="filteredConversationList.length" class="space-y-3">
<el-dropdown
v-for="item in filteredConversationList"
:key="item.id"
class="block w-full"
trigger="contextmenu"
@command="(cmd) => onConversationMenu(cmd, item)"
>
<button
type="button"
class="cursor-pointer w-full rounded-lg border p-3 text-left transition-all"
:class="
activeConversation?.id === item.id
? 'border-[#cbdcff] bg-[linear-gradient(180deg,#f6faff_0%,#edf5ff_100%)] shadow-[0_0.5rem_1.125rem_rgba(134,167,207,0.15)]'
: 'border-transparent bg-transparent hover:border-[#dde8f3] hover:bg-[#f9fbfe]'
"
@click="selectConversation(item)"
>
<div class="flex gap-3">
<div class="relative shrink-0">
<el-avatar :size="46" :src="item.other_user_avatar" />
<span
v-if="Number(item.had_read) === BooleanFlag.NO"
class="pointer-events-none absolute right-0 top-0 h-2.5 w-2.5 rounded-full border-2 border-white bg-[#f56c6c] shadow-[0_0_0_1px_rgba(245,108,108,0.35)]"
title="未读"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class="max-w-[6.875rem] truncate text-4 font-700 text-[#17305d]">
{{ item.other_user_name }}
</span>
</div>
<span class="shrink-0 text-3 text-[#8ca0b8]">{{
item.last_time_str
}}</span>
</div>
<div class="mt-2 flex min-w-0 items-center gap-2">
<span
class="inline-flex h-5 shrink-0 items-center rounded-full bg-[#fff3dc] px-2 text-3 text-[#9a6817]"
>
{{ item.chat_type_desc }}
</span>
<span
class="truncate text-base text-[#7890aa]"
v-html="parseEmoji(item.last_message)"
></span>
<span
v-if="item.un_read_count > 0"
class="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-[#f56c6c] px-1.5 text-3 font-700 text-white"
>
{{ item.un_read_count > 99 ? '99+' : item.un_read_count }}
</span>
</div>
</div>
</div>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete"> 删除会话 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-empty v-else description="没有找到匹配的会话" :image-size="90" />
</el-scrollbar>
</div>
</div>
</aside>
<main
class="overflow-hidden h-full flex-3/4 rounded-lg border border-[#d8e5f2] bg-white shadow-[0_0.5rem_1.5rem_rgba(134,167,207,0.12)]"
>
<template v-if="activeConversation">
<div class="flex h-full min-h-0 flex-col">
<header
class="relative z-1 flex flex-col gap-3 border-b border-[#e6eef6] bg-#fff px-5 py-4 shadow-[0_10px_16px_-16px_rgba(15,23,42,0.22)] md:flex-row md:items-center md:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<el-avatar :size="50" :src="activeConversation.other_user_avatar" />
<div class="min-w-0">
<div class="flex items-center gap-2">
<strong class="text-4.5 font-700 text-[#18305d]">{{
activeConversation.other_user_name
}}</strong>
<span
class="inline-flex h-6 items-center rounded-full px-2.5 text-3 font-700"
:class="
toChatType(activeConversation.chat_type) === BooleanFlag.NO
? 'bg-[#e7f7ed] text-[#2a7f61]'
: 'bg-[#e8f0ff] text-[#3762c7]'
"
>
{{ activeConversation.chat_type_desc }}
</span>
</div>
</div>
</div>
<div class="text-[0.8125rem] text-[#6f84a4]">
当前对话按
<span class="mx-1 font-700 text-[#4a6fd2]">{{
activeConversation.chat_type_desc
}}</span>
方式展示
</div>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<div
v-show="!messageDetailList.length"
class="flex h-full items-center justify-center"
>
<el-empty description="没有找到匹配的会话" :image-size="90" />
</div>
<el-scrollbar v-show="messageDetailList.length" ref="conversationBoxRef">
<div v-show="messageDetailList.length" class="px-5 py-5">
<div v-for="message in messageDetailList" :key="message.message_id" class="pb-3">
<div
class="mb-4 flex w-full min-w-0 cursor-default items-start gap-3"
:class="{ 'flex-row-reverse': message.is_self }"
>
<el-avatar :size="38" :src="message.sender_avatar" class="shrink-0" />
<div
class="flex max-w-[min(72%,38.75rem)] flex-col gap-1"
:class="{ 'items-end': message.is_self }"
>
<div class="flex items-center gap-2 text-3 text-[#8c9bb0]">
<span>{{
message.is_self ? userInfo.name : activeConversation.other_user_name
}}</span>
<span class="text-[#9eb0c6]">{{ message.create_time_str }}</span>
</div>
<div
class="w-fit max-w-full rounded-lg px-3 py-2 shadow-[0_0.125rem_0.5rem_rgba(39,74,120,0.04)]"
:class="
message.is_self
? 'rounded-tr-[0.375rem] bg-[#dbeaff]'
: 'rounded-tl-[0.375rem] bg-[#dff1fd]'
"
>
<el-dropdown
v-if="message.is_self"
class="inline-block max-w-full"
trigger="contextmenu"
@command="(cmd) => onMessageMenu(cmd, message)"
>
<p
v-if="message.content"
v-html="parseEmoji(message.content)"
class="cursor-pointer m-0 whitespace-pre-wrap break-words text-3.5 leading-[1.625rem]"
></p>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete"> 删除消息 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<p
v-else-if="message.content"
v-html="parseEmoji(message.content)"
class="m-0 whitespace-pre-wrap break-words text-3.5 leading-[1.625rem]"
></p>
<div
v-if="message.image_list?.length"
class="mt-2 flex flex-wrap gap-2"
:class="'justify-start'"
:style="{
width: message.image_list.length > 1 ? '15.5rem' : '7.5rem',
}"
>
<el-image
v-for="image in message.image_list"
:key="image"
:src="image"
fit="cover"
:preview-src-list="message.image_list"
preview-teleported
class="h-30 w-30 overflow-hidden rounded-lg"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</el-scrollbar>
</div>
<footer
class="relative z-1 h-auto border-t border-[#e6eef6] bg-white px-5 py-4 shadow-[0_-10px_16px_-16px_rgba(15,23,42,0.22)]"
>
<div
class="mb-3 flex items-center justify-between gap-3 text-[0.8125rem] text-[#7690ac]"
>
<div class="flex items-center gap-1.5">
<el-icon><IEpInfoFilled /></el-icon>
<span>按 Enter 发送,Shift + Enter 换行</span>
</div>
<span>{{ draftText.length }} / 250</span>
</div>
<div class="self-message-commentbox" @keydown="handleInputKeydown">
<CommentBox
v-model:inputText="draftText"
v-model:inputImg="draftImages"
:textAreaHeight="72"
placeholder="请输入回复内容"
:showMention="false"
>
<template #submit>
<button
type="button"
class="comment-publish-btn cursor-pointer disabled:opacity-50 px-6 py-2 text-white rounded-full text-sm hover:shadow-lg transition-all"
:disabled="!draftText?.trim() || sending"
@click="handleSend"
>
<div v-show="sending" class="flex items-center gap-2">
<el-icon><IEpLoading /></el-icon>
<span>发表中...</span>
</div>
<div v-show="!sending">发表</div>
</button>
</template>
</CommentBox>
</div>
</footer>
</div>
</template>
<div v-else class="flex h-full items-center justify-center">
<el-empty description="选择一个会话开始查看私信内容" :image-size="120" />
</div>
</main>
</section>
</div>
</template>
<style scoped>
:deep(.self-message-commentbox textarea) {
min-height: 4.25rem !important;
}
:deep(.self-message-commentbox .el-button--primary) {
border-radius: 0.875rem;
border-color: #85a7ff;
background: linear-gradient(90deg, #71b6ff 0%, #7f92ff 100%);
box-shadow: 0 0.375rem 1rem rgba(125, 146, 255, 0.2);
}
:deep(.self-message-commentbox .el-button--large) {
min-width: 5.75rem;
}
@media (max-width: 64rem) {
:deep(.self-message-commentbox .el-button--large) {
width: 100%;
}
}
/**
* 发表按钮:不用纯 Uno 渐变工具类。
* 旧版内核 / 部分企业微信 WebView 对「依赖 CSS 变量的 linear-gradient」解析失败时,
* 背景会变成透明或无效,原生 button 呈白底,易出现「只剩阴影、几乎看不见按钮」。
* 这里用实色 background-color 作兜底,并写死 hex + -webkit- 前缀渐变。
*/
.comment-publish-btn {
-webkit-appearance: none;
appearance: none;
margin: 0;
border: 1px solid rgba(255, 255, 255, 0.35);
/* 与 Tailwind blue-500 / purple-500 接近的实色兜底 */
background-color: #6366f1;
background-image: -webkit-linear-gradient(left, #3b82f6, #a855f7);
background-image: linear-gradient(to right, #3b82f6, #a855f7);
color: #ffffff;
}
.comment-publish-btn:disabled {
cursor: not-allowed;
}
</style>
......@@ -66,10 +66,16 @@
</template>
<script lang="tsx" setup>
import { getSelfAuctionRecord, getUserLotteryRecordList } from '@/api'
import { getSelfAuctionRecord, getUserLotteryRecordList, getUserWheelRecordList } from '@/api'
import { usePageSearch } from '@/hooks'
import { ActivityTypeEnum } from '@/constants/enums'
import type { UserLotteryRecordItemDto } from '@/api/dailyLottery/types'
import type { UserLotteryRecordItemDto, UserWheelRecordItemDto } from '@/api'
import { useUserStore } from '@/stores'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const isWareHouse = userInfo.value.address.includes('仓库')
const EmptyComp = () => (
<div class="flex flex-col items-center justify-center h-64">
......@@ -82,109 +88,169 @@ const EmptyComp = () => (
</div>
)
const activityTypeListOptions = [
{
label: '限时竞拍',
value: ActivityTypeEnum.AUCTION,
component: () => (
<>
<div class="flex justify-end">
<p class="text-gray-500 text-sm mb-1 flex items-center gap-1">
<el-icon>
<IEpInfoFilled />
</el-icon>
页面仅展示竞拍成功的记录
</p>
</div>
{!list1.value.length ? (
<EmptyComp />
) : (
const activityTypeListOptions: {
label: string
value: ActivityTypeEnum
component: () => VNode
refresh: () => void
}[] = []
if (!isWareHouse) {
activityTypeListOptions.push(
...[
{
label: '限时竞拍',
value: ActivityTypeEnum.AUCTION,
component: () => (
<>
<div class="space-y-4">
<el-table height="500" data={list1.value} stripe border loading={loading1.value}>
<el-table-column prop="name" label="名称" />
<el-table-column prop="startingPrice" label="起拍价" />
<el-table-column prop="bidPrice" label="支出YA币" />
</el-table>
</div>
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
<div class="pagination-wrapper bg-white rounded-lg shadow-sm border border-gray-100 p-2">
<el-pagination
v-model:current-page={searchParams1.value.current}
v-model:page-size={searchParams1.value.size}
onSizeChange={changePageSize1}
onCurrentChange={goToPage1}
page-sizes={[10, 20, 30, 40]}
layout="prev, pager, next, jumper, total"
total={total1.value}
class="custom-pagination"
/>
</div>
<div class="flex justify-end">
<p class="text-gray-500 text-sm mb-1 flex items-center gap-1">
<el-icon>
<IEpInfoFilled />
</el-icon>
页面仅展示竞拍成功的记录
</p>
</div>
{!list1.value.length ? (
<EmptyComp />
) : (
<>
<div class="space-y-4">
<el-table height="500" data={list1.value} stripe border loading={loading1.value}>
<el-table-column prop="name" label="名称" />
<el-table-column prop="startingPrice" label="起拍价" />
<el-table-column prop="bidPrice" label="支出YA币" />
</el-table>
</div>
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
<div class="pagination-wrapper bg-white rounded-lg shadow-sm border border-gray-100 p-2">
<el-pagination
v-model:current-page={searchParams1.value.current}
v-model:page-size={searchParams1.value.size}
onSizeChange={changePageSize1}
onCurrentChange={goToPage1}
page-sizes={[10, 20, 30, 40]}
layout="prev, pager, next, jumper, total"
total={total1.value}
class="custom-pagination"
/>
</div>
</div>
</>
)}
</>
)}
</>
),
refresh: () => refresh1(),
},
{
label: '每日抽奖',
value: ActivityTypeEnum.DAILY_LOTTERY,
component: () => (
<>
{!list2.value.length ? (
<EmptyComp />
) : (
),
refresh: () => refresh1(),
},
{
label: '每日抽奖',
value: ActivityTypeEnum.DAILY_LOTTERY,
component: () => (
<>
<div class="space-y-4">
<el-table height="500" data={list2.value} stripe border loading={loading2.value}>
<el-table-column prop="prizeName" label="名称" />
<el-table-column prop="activityDateRange" label="参与时间" />
<el-table-column prop="isLotteryDone" label="是否开奖">
{({ row }: { row: UserLotteryRecordItemDto }) => (
<div>{row.isLotteryDone ? <span></span> : <span>否</span>}</div>
)}
</el-table-column>
<el-table-column prop="isWin" label="是否中奖">
{({ row }: { row: UserLotteryRecordItemDto }) => (
<div>
{row.isLotteryDone ? (
row.isWin ? (
<span class="text-green-500"></span>
) : (
<span class="text-red-500"></span>
)
) : (
'暂未开奖'
{!list2.value.length ? (
<EmptyComp />
) : (
<>
<div class="space-y-4">
<el-table height="500" data={list2.value} stripe border loading={loading2.value}>
<el-table-column prop="prizeName" label="名称" />
<el-table-column prop="activityDateRange" label="参与时间" />
<el-table-column prop="isLotteryDone" label="是否开奖">
{({ row }: { row: UserLotteryRecordItemDto }) => (
<div>{row.isLotteryDone ? <span></span> : <span>否</span>}</div>
)}
</div>
)}
</el-table-column>
</el-table>
</div>
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
<div class="pagination-wrapper bg-white rounded-lg shadow-sm border border-gray-100 p-2">
<el-pagination
v-model:current-page={searchParams2.value.current}
v-model:page-size={searchParams2.value.size}
onSizeChange={changePageSize2}
onCurrentChange={goToPage2}
page-sizes={[10, 20, 30, 40]}
layout="prev, pager, next, jumper, total"
total={total2.value}
class="custom-pagination"
/>
</div>
</div>
</el-table-column>
<el-table-column prop="isWin" label="是否中奖">
{({ row }: { row: UserLotteryRecordItemDto }) => (
<div>
{row.isLotteryDone ? (
row.isWin ? (
<span class="text-green-500"></span>
) : (
<span class="text-red-500"></span>
)
) : (
'暂未开奖'
)}
</div>
)}
</el-table-column>
</el-table>
</div>
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
<div class="pagination-wrapper bg-white rounded-lg shadow-sm border border-gray-100 p-2">
<el-pagination
v-model:current-page={searchParams2.value.current}
v-model:page-size={searchParams2.value.size}
onSizeChange={changePageSize2}
onCurrentChange={goToPage2}
page-sizes={[10, 20, 30, 40]}
layout="prev, pager, next, jumper, total"
total={total2.value}
class="custom-pagination"
/>
</div>
</div>
</>
)}
</>
)}
</>
),
refresh: () => refresh2(),
},
]
const tab = ref(activityTypeListOptions[0]!.value)
),
refresh: () => refresh2(),
},
{
label: '大转盘',
value: ActivityTypeEnum.WHEEL,
component: () => (
<>
{!list3.value.length ? (
<EmptyComp />
) : (
<>
<div class="space-y-4">
<el-table height="500" data={list3.value} stripe border loading={loading3.value}>
<el-table-column prop="prizeName" label="名称">
{({ row }: { row: UserWheelRecordItemDto }) => (
<div>{row.prizeName ?? `谢谢参与(${row.blessingText})`}</div>
)}
</el-table-column>
<el-table-column prop="createdAtStr" label="参与时间" />
<el-table-column prop="isLotteryDone" label="是否中奖">
{({ row }: { row: UserWheelRecordItemDto }) => (
<div>
{row.isWin ? (
<span class="text-green-500"></span>
) : (
<span class="text-red-500"></span>
)}
</div>
)}
</el-table-column>
</el-table>
</div>
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
<div class="pagination-wrapper bg-white rounded-lg shadow-sm border border-gray-100 p-2">
<el-pagination
v-model:current-page={searchParams3.value.current}
v-model:page-size={searchParams3.value.size}
onSizeChange={changePageSize3}
onCurrentChange={goToPage3}
page-sizes={[10, 20, 30, 40]}
layout="prev, pager, next, jumper, total"
total={total3.value}
class="custom-pagination"
/>
</div>
</div>
</>
)}
</>
),
refresh: () => refresh3(),
},
],
)
}
const tab = ref(activityTypeListOptions[0]?.value || ActivityTypeEnum.AUCTION)
const toggleTab = () => {
refresh()
......@@ -212,7 +278,17 @@ const {
} = usePageSearch(getUserLotteryRecordList, {
immediate: false,
})
const {
list: list3,
loading: loading3,
searchParams: searchParams3,
total: total3,
refresh: refresh3,
goToPage: goToPage3,
changePageSize: changePageSize3,
} = usePageSearch(getUserWheelRecordList, {
immediate: false,
})
const refresh = () => {
activityTypeListOptions.find((item) => item.value === tab.value)?.refresh?.()
}
......
......@@ -99,7 +99,7 @@
<div class="bg-white rounded-lg shadow-sm mb-4">
<div
v-for="item in menuUserItems"
:key="item.path"
:key="item.tab"
@click="changeMenu(item.path)"
:class="[
'flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-gray-100 last:border-b-0',
......@@ -112,6 +112,12 @@
<component :is="item.icon" />
</el-icon>
<span class="text-sm">{{ item.label }}</span>
<el-badge
v-if="item.tab === '我的私信'"
:value="privateMessageUnreadDisplay > 99 ? '99+' : privateMessageUnreadDisplay"
:hidden="privateMessageUnreadDisplay <= 0"
class="ml-auto"
/>
</div>
</div>
<!-- 左侧菜单 —— 官方账号菜单 审核操作等 -->
......@@ -164,11 +170,10 @@
<script lang="tsx" setup>
import { storeToRefs } from 'pinia'
import EditUserInfo from './components/editUserInfo.vue'
import { generateLoginKey, hasOfficialAccount } from '@/api'
import { generateLoginKey, hasOfficialAccount, getMessageList } from '@/api'
import type { OfficialAccountItemDto } from '@/api/user/types'
import { wxLogin } from '@/utils/wxUtil'
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'
import type { TabPaneName } from 'element-plus'
import { IS_REAL_KEY } from '@/constants/symbolKey'
import { useOnlineTimeStore, useUserStore } from '@/stores'
import BackButton from '@/components/common/BackButton/index.vue'
......@@ -179,17 +184,17 @@ const { showOnlineTime } = storeToRefs(useOnlineTimeStore())
const router = useRouter()
const route = useRoute()
// 当前激活的菜单 用计算属性 好办法!
// 当前激活的菜单 用计算属性
const activeMenu = computed(() => {
const path = route.path
if (path.includes('userPage')) {
return path.split('/').at(-1) || 'selfPublish'
return path || '/userPage/selfPublish'
}
return 'selfPublish'
return '/userPage/selfPublish'
})
const getThirdLevelKey = (route: RouteLocationNormalizedLoadedGeneric) => {
console.log(route.fullPath, '三级路由用户信息页面')
// console.log(route.fullPath, '三级路由用户信息页面')
return route.path
}
const componentRef = useTemplateRef<{
......@@ -198,75 +203,83 @@ const componentRef = useTemplateRef<{
const editUserInfoRef = useTemplateRef<InstanceType<typeof EditUserInfo>>('editUserInfoRef')
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const anonymousUnreadCount = ref(0)
const realUnreadCount = ref(0)
// 左侧普通用户菜单
const menuUserItems = [
{
path: () => `/selfMessage?isReal=${isReal.value}`,
label: '我的私信',
icon: () => <IEpMessage />,
tab: '我的私信',
},
{
path: 'selfPublish',
path: '/userPage/selfPublish',
label: '我的帖子',
icon: () => <IEpUser />,
tab: '发布',
},
{
path: 'selfDraft',
path: '/userPage/selfDraft',
label: '我的草稿',
icon: () => <IEpDocument />,
tab: '草稿',
},
{
path: 'selfCollect',
path: '/userPage/selfCollect',
label: '我的收藏',
icon: () => <IEpStar />,
tab: '收藏',
},
{
path: 'selfPraise',
path: '/userPage/selfPraise',
label: '我的点赞',
icon: () => <IEpPointer />,
tab: '点赞',
},
{
path: 'selfCase',
path: '/userPage/selfCase',
label: '我的案例库',
icon: () => <IEpCollection />,
tab: '案例库',
},
{
path: 'selfTask',
path: '/userPage/selfTask',
label: '我的任务',
icon: () => <IEpFinished />,
tab: '任务',
},
{
path: 'selfActivity',
path: '/userPage/selfActivity',
label: '参与活动',
icon: () => <IEpTrophy />,
tab: '活动',
},
{
path: 'selfComment',
path: '/userPage/selfComment',
label: '评论回复',
icon: () => <IEpChatDotRound />,
tab: '评论回复',
},
{
path: 'selfAnswer',
path: '/userPage/selfAnswer',
label: '回答问题(问吧)',
icon: () => <IEpChatLineSquare />,
tab: '回答问题',
},
}
]
// 左侧官方账号菜单
const menuOfficialItems = [
{
path: 'selfAudit',
path: '/userPage/selfAudit',
label: '审核列表',
icon: () => <IEpUser />,
tab: '审核列表',
},
{
path: 'selfComplaint',
path: '/userPage/selfComplaint',
label: '举报列表',
icon: () => <IEpWarning />,
tab: '举报列表',
......@@ -275,6 +288,11 @@ const menuOfficialItems = [
const isReal = ref<BooleanFlag>(BooleanFlag.NO)
/** 与私信页一致:实名 → realUnreadCount,匿名 → anonymousUnreadCount */
const privateMessageUnreadDisplay = computed(() =>
isReal.value === BooleanFlag.YES ? realUnreadCount.value : anonymousUnreadCount.value,
)
provide(IS_REAL_KEY, isReal)
watch(isReal, () => {
......@@ -296,12 +314,11 @@ const currentUserInfo = computed(() =>
},
)
const changeMenu = (key: TabPaneName) => {
router.push(`/userPage/${key}`)
const changeMenu = (key: string | (() => string)) => {
router.push(typeof key === 'function' ? key() : key)
}
const handleEdit = () => {
console.log('修改资料')
editUserInfoRef.value?.open({
hiddenAvatar: userInfo.value.hiddenAvatar,
hiddenName: userInfo.value.hiddenName,
......@@ -315,6 +332,17 @@ const getIsOfficial = async () => {
officialAccountList.value = data
}
const getSelfMessageUnreadCount = async () => {
try {
const { data } = await getMessageList({})
anonymousUnreadCount.value = data?.anonymousUnreadCount ?? 0
realUnreadCount.value = data?.realUnreadCount ?? 0
} catch {
anonymousUnreadCount.value = 0
realUnreadCount.value = 0
}
}
const handleSwitchAccount = async () => {
const selectedEmail = ref('')
ElMessageBox({
......@@ -423,6 +451,10 @@ const handleClearCache = async () => {
onMounted(() => {
getIsOfficial()
})
onActivated(() => {
getSelfMessageUnreadCount()
})
</script>
<style scoped>
......
......@@ -103,7 +103,9 @@ const selectAmount = (amount: number) => {
// 确认打赏
const handleConfirm = async () => {
if (yabiData.value.currentValue < selectedAmount.value) {
push.warning('余额不足,请先充值')
push.error(
`您的YA币不足,打赏所需${selectedAmount.value}YA币,当前YA币${yabiData.value.currentValue}`,
)
return
}
......
......@@ -126,10 +126,23 @@
{{ videoDetail?.createUserName }}
</h3>
</div>
<button
v-if="!isVideoAuthor && videoDetail?.createUserId"
type="button"
class="mr-1 sm:mr-2 inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg border border-blue-100/90 shadow-sm hover:bg-blue-100 hover:border-blue-200/80 active:scale-[0.98] transition-all cursor-pointer shrink-0"
@click="
sendMessageDialogRef?.open({
receiverId: String(videoDetail.createUserId),
isReal: BooleanFlag.NO,
})
"
>
私信
</button>
</div>
<!-- 右侧:互动按钮 -->
<div class="flex items-center">
<div class="flex items-center flex-wrap gap-2 sm:gap-0">
<!-- 浏览量 -->
<el-button
text
......@@ -313,6 +326,7 @@
<RewardDialog ref="rewardDialogRef" v-model:rewardNum="videoDetail.rewardNum" />
<RewardToast ref="rewardToastRef" :rewardVideoType="rewardVideoType" />
<RewardFullSetToast ref="rewardFullSetToastRef" @hided="videoRef?.play()" />
<SendMessageDialog ref="sendMessageDialogRef" />
</div>
</template>
<script lang="ts" setup>
......@@ -331,10 +345,12 @@ import RewardDialog from './components/rewardDialog.vue'
import RewardToast from './components/rewardToast.vue'
import RewardFullSetToast from './components/rewardFullSetToast.vue'
import ActionMore from '@/components/common/ActionMore/index.vue'
import SendMessageDialog from '@/components/common/SendMessageDialog/index.vue'
import BackButton from '@/components/common/BackButton/index.vue'
import { useNavigation } from '@/hooks'
import {
ArticleTypeEnum,
BooleanFlag,
SpecificVideoRewardEnum,
specificVideoRewardListOptions,
} from '@/constants'
......@@ -379,6 +395,11 @@ const watchedSecondsObj = useStorage(`watched-seconds-obj-${userInfo.value.userI
})
const rewardDialogRef = useTemplateRef<InstanceType<typeof RewardDialog> | null>('rewardDialogRef')
const sendMessageDialogRef = useTemplateRef<InstanceType<typeof SendMessageDialog> | null>(
'sendMessageDialogRef',
)
const isVideoAuthor = computed(() => videoDetail.value.createUserId === userInfo.value.userId)
const rewardVideoLimitDuration = ref(0)
const specificVideoRewardItem = computed(() => {
return specificVideoRewardListOptions.find((item) =>
......
......@@ -15,10 +15,12 @@ import Icons from 'unplugin-icons/vite'
import path from 'node:path'
// 获取到执行脚本的--参数
import pkg from './package.json'
// @ts-ignore
import { getCurrentBranchName, getDeploySlug } from './deploy/util.js'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
return {
base: mode === 'test' ? `/${getDeploySlug(getCurrentBranchName())}/` : '/',
define: {
__CORE_LIB_VERSION__: JSON.stringify(pkg.dependencies),
},
......@@ -62,7 +64,7 @@ export default defineConfig(({ mode }) => {
symbolId: 'icon-[dir]-[name]',
}),
mode === 'development' && visualizer(), // 开发环境打包才需要
pushUpdatePlugin(),
mode === 'production' && pushUpdatePlugin(), // 生产环境打包才更新推送
],
server: {
// 是否开启 https
......@@ -80,34 +82,23 @@ export default defineConfig(({ mode }) => {
// },
},
build: {
minify: 'esbuild',
minify: true, // 'esbuild'
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
assetFileNames: 'assets/[ext]/[name]-[hash][extname]',
chunkFileNames: 'assets/js/[name]-[hash].js',
manualChunks: (id) => {
if (id.includes('node_modules')) {
if (id.includes('element-plus')) {
return 'element-plus'
} else if (id.includes('vue')) {
return 'vue'
} else if (id.includes('lodash-es')) {
return 'lodash-es'
} else if (id.includes('dayjs')) {
return 'dayjs'
} else if (id.includes('lodash-es')) {
return 'lodash-es'
} else if (id.includes('axios')) {
return 'axios'
} else if (id.includes('wangeditor')) {
return 'wangeditor'
}
// console.log(id)
return 'vendor'
}
codeSplitting: {
groups: [
{ name: 'elementPlus-vendor', test: /node_modules[\\/]element-plus/, priority: 9 },
{ name: 'vue-vendor', test: /node_modules[\\/]vue/, priority: 10 },
{ name: 'wangeditor-vendor', test: /node_modules[\\/]@wangeditor/, priority: 8 },
{
name: 'common',
minShareCount: 2,
priority: 5,
},
],
},
},
},
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment