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