mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-22 01:47:58 +00:00
webui4frpc: 独立可用的可视化 frpc 控制器 (M0)
- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus) - 画布多对多连线,渲染 tcp/udp/http/https frpc 配置 - worker 进程管理:自愈、日志轮转、崩溃退避重启 - frpc 一键安装 (GitHub Releases) + 手动指定路径 - 三页 UI:状态(默认)/连接配置/设置 - 状态页实时节点/转发状态,节点可增删改启停 - 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存 - backend 单测覆盖 store/render/process/httpapi/install - plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
166
web/src/components/LocalNode.vue
Normal file
166
web/src/components/LocalNode.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="local-node" :class="{ selected, conflicted }">
|
||||
<!-- source handle (right) connects to remote -->
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
|
||||
<div class="node-head">
|
||||
<span class="node-icon">⬢</span>
|
||||
<input v-model="nameField" class="name-input" />
|
||||
<button
|
||||
class="del-btn"
|
||||
title="删除"
|
||||
@click.stop="emit('remove', props.data.name)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<label>IP <input v-model="ipField" /></label>
|
||||
<label>端口 <input v-model.number="portField" type="number" /></label>
|
||||
<label
|
||||
>协议
|
||||
<select v-model="protocolField">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import type { CanvasLocal } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: CanvasLocal
|
||||
selected?: boolean
|
||||
conflicted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:data', data: CanvasLocal): void
|
||||
(e: 'remove', name: string): void
|
||||
}>()
|
||||
|
||||
const field = <K extends keyof CanvasLocal>(key: K) =>
|
||||
computed({
|
||||
get: () => props.data[key],
|
||||
set: (val: CanvasLocal[K]) => {
|
||||
emit('update:data', { ...props.data, [key]: val })
|
||||
},
|
||||
})
|
||||
|
||||
const nameField = field('name')
|
||||
const ipField = field('ip')
|
||||
const portField = field('port')
|
||||
const protocolField = field('protocol')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.local-node {
|
||||
min-width: 190px;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.18);
|
||||
padding: 8px 10px;
|
||||
|
||||
&.selected {
|
||||
border-color: #ffd54f;
|
||||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||||
}
|
||||
|
||||
&.conflicted {
|
||||
border-color: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
color: #4caf50;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2e7d32;
|
||||
&:focus {
|
||||
outline: 1px solid #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #81c784;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 64px;
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
330
web/src/components/PortEdge.vue
Normal file
330
web/src/components/PortEdge.vue
Normal file
@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<g class="port-edge" :class="{ selected }">
|
||||
<path
|
||||
:d="path"
|
||||
class="port-edge-path"
|
||||
:class="{ selected, conflicted }"
|
||||
fill="none"
|
||||
:stroke="strokeColor"
|
||||
:stroke-width="selected ? 3.5 : 2.5"
|
||||
/>
|
||||
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
|
||||
so users can place the port anywhere along/around the connection. -->
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
ref="labelEl"
|
||||
class="port-label"
|
||||
:class="{ dragging }"
|
||||
:style="labelStyle"
|
||||
@pointerdown.stop.prevent="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
>
|
||||
{{ displayPort }}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { EdgeLabelRenderer } from '@vue-flow/core'
|
||||
import type { EdgeProps } from '@vue-flow/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<EdgeProps & { conflicted?: boolean }>(),
|
||||
{
|
||||
selected: false,
|
||||
conflicted: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'label-click', payload: { edgeId: string }): void
|
||||
(
|
||||
e: 'label-drag',
|
||||
payload: { edgeId: string; offset: { x: number; y: number } },
|
||||
): void
|
||||
}>()
|
||||
|
||||
// User-dragged offset relative to the curve midpoint, initialized from the
|
||||
// edge data so it survives saves.
|
||||
const dragOffset = ref<{ x: number; y: number }>({
|
||||
x: Number(props.data?.offsetX ?? 0),
|
||||
y: Number(props.data?.offsetY ?? 0),
|
||||
})
|
||||
|
||||
// Press-to-drag using Pointer Events + pointer capture. Once the pointer is
|
||||
// captured on the label element, all subsequent pointer events (even outside
|
||||
// the element) are delivered to it, and pointerup reliably ends the drag.
|
||||
const labelEl = ref<HTMLElement | null>(null)
|
||||
const clickSlop = 2 // px of total travel that still counts as a click
|
||||
let moved = false
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startOff = { x: 0, y: 0 }
|
||||
|
||||
let dragging = false
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
// Never let this press reach the Vue Flow pane (which would pan the canvas).
|
||||
e.stopPropagation()
|
||||
dragging = true
|
||||
moved = false
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
startOff = { ...dragOffset.value }
|
||||
if (labelEl.value) {
|
||||
try {
|
||||
labelEl.value.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// Capture can fail in rare cases; the pointermove/up listeners below
|
||||
// still work while the pointer stays over the label.
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const dx = e.clientX - startX
|
||||
const dy = e.clientY - startY
|
||||
if (Math.abs(dx) > clickSlop || Math.abs(dy) > clickSlop) {
|
||||
moved = true
|
||||
}
|
||||
// The label lives in canvas (world) coordinates; pointer deltas are in
|
||||
// screen pixels, so divide by the current viewport zoom.
|
||||
const z = readZoom()
|
||||
dragOffset.value = {
|
||||
x: startOff.x + dx / z,
|
||||
y: startOff.y + dy / z,
|
||||
}
|
||||
emit('label-drag', {
|
||||
edgeId: props.id,
|
||||
offset: { ...dragOffset.value },
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
const wasDrag = dragging
|
||||
dragging = false
|
||||
if (labelEl.value && e.pointerId != null) {
|
||||
try {
|
||||
labelEl.value.releasePointerCapture?.(e.pointerId)
|
||||
} catch {
|
||||
// releasePointerCapture may throw if capture was never established.
|
||||
}
|
||||
}
|
||||
// Press without real movement = tap: open the port editor.
|
||||
if (!wasDrag) return
|
||||
if (!moved) {
|
||||
emit('label-click', { edgeId: props.id })
|
||||
}
|
||||
}
|
||||
|
||||
// readZoom parses the current zoom level from the Vue Flow viewport's
|
||||
// transform (e.g. "translate(10px, 20px) scale(0.85)"). Falls back to 1.
|
||||
function readZoom(): number {
|
||||
const vp = document.querySelector('.vue-flow__viewport') as HTMLElement | null
|
||||
if (!vp) return 1
|
||||
const m = /scale\(([0-9.]+)\)/.exec(vp.style.transform || '')
|
||||
if (!m) return 1
|
||||
const z = Number(m[1])
|
||||
return z > 0 ? z : 1
|
||||
}
|
||||
|
||||
// {{ layer }} is a per-pair index assigned by the canvas editor. It bends this
|
||||
// edge away from its siblings so multiple links between the same two nodes do
|
||||
// not overlap. Rendering order follows the layer too, so later links are drawn
|
||||
// above earlier ones.
|
||||
const layer = computed(() => Number(props.data?.layer ?? 0))
|
||||
|
||||
const displayPort = computed(() => {
|
||||
const t = String(props.label ?? '')
|
||||
return t || '?'
|
||||
})
|
||||
|
||||
// Direction of the base bend alternates so pairs of links spread symmetrically
|
||||
// above and below the straight line.
|
||||
const baseBend = computed(() => {
|
||||
const l = layer.value
|
||||
const dir = l % 2 === 0 ? 1 : -1
|
||||
const mag = (Math.floor(l / 2) + 1) * 16
|
||||
return dir * mag
|
||||
})
|
||||
|
||||
// totalBend adds the user's vertical drag onto the base layer bend so the
|
||||
// curve follows the dragged label.
|
||||
const totalBend = computed(() => baseBend.value + dragOffset.value.y)
|
||||
|
||||
const path = computed(() => {
|
||||
const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } =
|
||||
props
|
||||
return buildBentPath(
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
totalBend.value,
|
||||
)
|
||||
})
|
||||
|
||||
// The label sits at the curve midpoint, shifted horizontally by the user's
|
||||
// drag offset (vertical drag is already absorbed into totalBend).
|
||||
const mid = computed(() => midpointOf(path.value))
|
||||
|
||||
const labelStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
transform: `translate(${mid.value.x + dragOffset.value.x}px, ${mid.value.y}px) translate(-50%, -50%)`,
|
||||
pointerEvents: 'all' as const,
|
||||
cursor: 'grab',
|
||||
zIndex: 10,
|
||||
}))
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
if (props.conflicted) {
|
||||
return '#f5222d'
|
||||
}
|
||||
const l = layer.value
|
||||
// Muted, UI-friendly palette that matches the existing theme (Element Plus
|
||||
// primary / success / warning / danger / info), cycled per layer.
|
||||
const hues = [
|
||||
'#409eff',
|
||||
'#67c23a',
|
||||
'#e6a23c',
|
||||
'#f56c6c',
|
||||
'#909399',
|
||||
'#409eff',
|
||||
]
|
||||
return hues[l % hues.length]
|
||||
})
|
||||
|
||||
// buildBentPath draws a cubic bezier from the source handle to the target
|
||||
// handle, bending the control points vertically by (bend) pixels so parallel
|
||||
// links between the same nodes separate visually.
|
||||
function buildBentPath(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
sourcePosition: unknown,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
targetPosition: unknown,
|
||||
bend: number,
|
||||
): string {
|
||||
const horizontal = Math.abs(targetX - sourceX)
|
||||
const handleLen = Math.max(horizontal * 0.45, 40)
|
||||
let sx = sourceX
|
||||
let sy = sourceY
|
||||
let tx = targetX
|
||||
let ty = targetY
|
||||
|
||||
if (sourcePosition === 'Right') {
|
||||
sx = sourceX
|
||||
} else if (sourcePosition === 'Left') {
|
||||
sx = sourceX - handleLen
|
||||
} else if (sourcePosition === 'Top') {
|
||||
sx = sourceX
|
||||
sy = sourceY
|
||||
} else {
|
||||
sx = sourceX
|
||||
}
|
||||
|
||||
if (targetPosition === 'Left') {
|
||||
tx = targetX
|
||||
} else if (targetPosition === 'Right') {
|
||||
tx = targetX + handleLen
|
||||
} else if (targetPosition === 'Top') {
|
||||
ty = targetY
|
||||
}
|
||||
|
||||
const c1x = sx + (tx - sx) * 0.5
|
||||
const c1y = sy + bend
|
||||
const c2x = tx - (tx - sx) * 0.5
|
||||
const c2y = ty + bend
|
||||
return `M ${sx} ${sy} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${tx} ${ty}`
|
||||
}
|
||||
|
||||
// midpointOf approximates the curve center using the quadratic mean of the two
|
||||
// evaluated bezier points at t=0.5 (single evaluation is enough for labels).
|
||||
function midpointOf(path: string): { x: number; y: number } {
|
||||
// Parse the cubic bezier control points out of the path string.
|
||||
const nums = path
|
||||
.replace('M', '')
|
||||
.replace('C', '')
|
||||
.split(/[ ,]+/)
|
||||
.filter((s) => s.length > 0)
|
||||
.map(Number)
|
||||
if (nums.length < 8) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
const x0 = nums[0] ?? 0
|
||||
const y0 = nums[1] ?? 0
|
||||
const cx1 = nums[2] ?? 0
|
||||
const cy1 = nums[3] ?? 0
|
||||
const cx2 = nums[4] ?? 0
|
||||
const cy2 = nums[5] ?? 0
|
||||
const x1 = nums[6] ?? 0
|
||||
const y1 = nums[7] ?? 0
|
||||
const t = 0.5
|
||||
const mt = 1 - t
|
||||
const x =
|
||||
mt * mt * mt * x0 +
|
||||
3 * mt * mt * t * cx1 +
|
||||
3 * mt * t * t * cx2 +
|
||||
t * t * t * x1
|
||||
const y =
|
||||
mt * mt * mt * y0 +
|
||||
3 * mt * mt * t * cy1 +
|
||||
3 * mt * t * t * cy2 +
|
||||
t * t * t * y1
|
||||
return { x, y }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.port-edge-path {
|
||||
&.selected {
|
||||
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
|
||||
}
|
||||
&.conflicted {
|
||||
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.port-label {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: $color-text-primary;
|
||||
border: 1px solid $color-border-light;
|
||||
border-radius: 10px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow $transition-fast,
|
||||
transform $transition-fast;
|
||||
|
||||
&.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
web/src/components/RemoteNode.vue
Normal file
172
web/src/components/RemoteNode.vue
Normal file
@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="remote-node" :class="{ selected, conflicted }">
|
||||
<!-- target handle (left) receives connections from locals -->
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
|
||||
<div class="node-head">
|
||||
<span class="node-icon">⬤</span>
|
||||
<input v-model="nameField" class="name-input" />
|
||||
<label class="enabled">
|
||||
<input v-model="enabledField" type="checkbox" />
|
||||
启用
|
||||
</label>
|
||||
<button
|
||||
class="del-btn"
|
||||
title="删除"
|
||||
@click.stop="emit('remove', props.data.name)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<label>IP <input v-model="ipField" /></label>
|
||||
<label>端口 <input v-model.number="portField" type="number" /></label>
|
||||
<label>令牌 <input v-model="tokenField" /></label>
|
||||
<label>URL <input v-model="urlField" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import type { CanvasRemote } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: CanvasRemote
|
||||
selected?: boolean
|
||||
conflicted?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:data', data: CanvasRemote): void
|
||||
(e: 'remove', name: string): void
|
||||
}>()
|
||||
|
||||
const field = <K extends keyof CanvasRemote>(key: K) =>
|
||||
computed({
|
||||
get: () => props.data[key],
|
||||
set: (val: CanvasRemote[K]) => {
|
||||
emit('update:data', { ...props.data, [key]: val })
|
||||
},
|
||||
})
|
||||
|
||||
const nameField = field('name')
|
||||
const ipField = field('ip')
|
||||
const portField = field('port')
|
||||
const tokenField = field('token')
|
||||
const urlField = field('url')
|
||||
const enabledField = field('enabled')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.remote-node {
|
||||
min-width: 200px;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: #fff3e0;
|
||||
border: 2px solid #ff9800;
|
||||
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.18);
|
||||
padding: 8px 10px;
|
||||
|
||||
&.selected {
|
||||
border-color: #ffd54f;
|
||||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||||
}
|
||||
|
||||
&.conflicted {
|
||||
border-color: #f5222d;
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conflict-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
color: #ff9800;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #e65100;
|
||||
&:focus {
|
||||
outline: 1px solid #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
.enabled {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ffb74d;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #ef6c00;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #ffcc80;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 64px;
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user